Compare commits

...
290 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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
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 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 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 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 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 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
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
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 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 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
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 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 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 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 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 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 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 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 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 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
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
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
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
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 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
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 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 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
332 changed files with 38650 additions and 9215 deletions
+4 -4
View File
@@ -1,5 +1,6 @@
# Local configuration and credentials
config.json
allow_remote_changes
.env
.env.*
!.env.example
@@ -77,10 +78,9 @@ 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
+11 -12
View File
@@ -8,10 +8,10 @@
以下几条每次提交都要做到,没有“这次改动小”的例外;其余各节是怎么做好的指引。
1. **结构和文档同步。** 按下表更新文档,与代码放在同一次提交里。新增、删除、改名、拆分源码文件一律算改了模块职责:职责清单同时是文件目录,每个源码文件都要有一行。
2. **跑结构检查。** `node tools/check_structure.mjs`,失败不提交。它检查每个源码文件都登记在架构文档里、每个前端模块都能从入口引用到、每个前端文件都能按浏览器的方式解析(`node --check` 对 .js 文件会漏掉浏览器报错的写法),并执行规模阈值(第 2 节):新文件不能一开始就超线,已超线的文件比上次提交只能变短或持平。
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. **测试产物不进仓库。** 截图、录屏、日志、原始测试数据、审计附件放在 `.agents/`(已忽略),不用 `git add -f` 绕过忽略规则。提交的文件里不写本机路径、用户名或 `file:///` 链接,文档之间用相对链接。
5. **测试产物不进仓库。** 测试代码放 `tests/`;截图、录屏、日志、原始测试数据、审计附件放在 `.agents/`(已忽略,只留本机),不用 `git add -f` 绕过忽略规则。提交的文件里不写本机路径、用户名或 `file:///` 链接,文档之间用相对链接。
6. **交付说明**写清:改了什么和为什么、验证结果和没验证到的地方、遗留问题、提交号;用了规范例外就写原因和影响。
| 改了…… | 更新 |
@@ -20,8 +20,8 @@
| 数据流、接口、持久化格式、安全边界、跨系统规则 | 对应的 `docs/architecture/` 专题;系统图或阅读地图变了才改 `ARCHITECTURE.md` 入口 |
| 用户能感觉到的行为 | `CHANGELOG.md`;使用方法变了同步 `README.md` 或 `docs/guides/` |
| 长期有效的产品或架构取舍 | `docs/decisions/` |
| 反复出现的坑、严重故障 | `.agents/logs/ai_lessons.md` |
| 开发规范本身 | 只改本文件;`CLAUDE.md`、`GEMINI.md` 只做阅读入口 |
| 反复出现的坑、严重故障 | `docs/architecture/lessons.md` |
| 开发规范本身 | 只改本文件 |
普通修复、样式微调不写进架构文档;没有变化的边界不写“本次未改变”。
@@ -107,8 +107,8 @@ ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalo
1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。
2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。
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. **读用户文件要有上限。** 限制读取的大小和条数;读不了或超大的文件跳过并在结果里说明,不让一个坏文件拖垮整个请求。读到的内容进页面只当纯文本(本节第二条)。
@@ -137,11 +137,10 @@ ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应
| 修改范围 | 至少验证 |
| --- | --- |
| 导入、导出、模块拆分 | JS 语法、模块链接,以及受影响按钮的真实执行路径 |
| 工坊状态与生命周期 | `node --experimental-vm-modules tests/prompt_ui_lifecycle.mjs` |
| 词卡读取、分页 | `node tests/prompt_material_source.mjs` |
| 翻译桥接 | `node tests/translation_service_contracts.mjs`,异步节点写入另跑工坊生命周期测试 |
| 素材应用、笔记隔离 | `node --experimental-vm-modules tests/material_workspace_flow.mjs` |
| 素材提示词协议、配方筛选、拖拽清理 | `node --experimental-vm-modules tests/studio_contracts.mjs` |
| 提示词工坊、词块 | `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 差异 |
@@ -150,7 +149,7 @@ ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应
- 修复故障时,优先补充能复现原故障的行为测试。不能只通过源码字符串“存在某段代码”来证明修复。
- UX 明确替换旧交互时,更新测试到新的用户流程,并保留原本要保护的行为;不要仅删除失败断言或重新塞回废弃功能。
- 新增应随仓库交付的回归测试要检查 `.gitignore`,确认确实进入版本控制。
- 历史本地 Python 测试可能不在 Git 中。使用合适环境运行相关测试,例如 `python -m unittest discover -s tests -p 'test_*.py'`,核对实际发现的数量;零项测试不算通过。
- Python 测试要在 ComfyUI 的 Python 环境里跑(`node tools/run_tests.mjs` 会自己找),核对实际发现的数量;零项测试不算通过。
- 无法运行真实宿主、网络服务或完整测试时,准确说明缺口;不要将模拟画布、语法检查或已有通过数量称为本轮宿主验证。
## 8. 文档与交付
+272 -60
View File
@@ -9,7 +9,6 @@ by default.
Before implementation, follow [AGENTS.md](AGENTS.md), the canonical development
and maintenance rules; its section 0 is required for every commit.
[GEMINI.md](GEMINI.md) and [CLAUDE.md](CLAUDE.md) are reading entry points only.
Saved prompts and saved node values are material files (`anomalous_materials`); there is no
Material Library page any more. Prompt Studio lists the saved prompts (reading each body
@@ -25,7 +24,7 @@ and Current node lists the saved values. See the material contract below.
| Saved prompts and node values (material files), image parameter details, keeping an output image | [`docs/architecture/material-library.md`](docs/architecture/material-library.md) |
| Model Check, provenance hashes, missing-model recovery, or model scanning | [`docs/architecture/model-resolution.md`](docs/architecture/model-resolution.md) |
| Why a current product boundary exists | [`docs/decisions/README.md`](docs/decisions/README.md) |
| Recurring implementation mistakes and post-mortems | [`.agents/logs/ai_lessons.md`](.agents/logs/ai_lessons.md) |
| Recurring implementation mistakes and post-mortems | [`docs/architecture/lessons.md`](docs/architecture/lessons.md) |
`README.md` is user-facing documentation and `CHANGELOG.md` is user-facing
release history. Neither is the source of truth for internal architecture.
@@ -83,15 +82,22 @@ covered by the `styles.css` manifest.
and no scan replaces or deletes it.
- `api/model_catalog.py`, `api/model_resolution.py`, `api/model_metadata.py`, and
`api/model_media.py` own model listing, identity recovery, mutation, and covers;
`api/model_folder.py` shows a model file in Explorer / Finder (by type, folder index and
file name, never a path; a POST, so only this computer may ask);
`api/model_folder.py` shows a model file in Explorer / Finder, or without a file name opens
the models folder (by type, folder index, subfolder and file name, never a path; a POST, so
only this computer may ask);
`api/models.py` is a compatibility facade; `api/model_constants.py` holds the
model, media and sidecar extensions they share. `api/model_type_listing.py` lists a
whole models folder with its subfolders for the models page's type chips.
- `api/scanner.py` and `scraper.py` own scan orchestration and enrichment; both scan
routes map request fields to scraper switches in one place (`_scraper_flags`).
`civitai_client.py` is the scraper's network side (version by hash, model page, media),
telling "Civitai does not know the file" from "Civitai could not be asked".
telling "Civitai does not know the file" from "Civitai could not be asked" (and why); it
asks Civitai's other address (civitai.red) when civitai.com does not answer (`api_hosts`,
also for import, Model Check and the network test).
`net_proxy.py` is the proxy every request of the plugin goes through (Settings → Network,
`NETWORK_PROXY` in api/config.json; empty = urllib's own: HTTPS_PROXY or the Windows proxy),
handed to the scan process as HTTP(S)_PROXY, and the names of why a request failed;
`api/network_check.py` (`GET /anomalous/civitai_check`, this computer only) tests Civitai through it.
`api/scan_report.py` turns the scraper's per-model report lines into one result per
scan: kept as the last scan (`/anomalous/last_scan`), recorded in the activity log, each
model located so the browser can open it (`/anomalous/scan_model`).
@@ -100,7 +106,70 @@ covered by the `styles.css` manifest.
`api/scan_summary.py` counts models matched on Civitai, unmatched and not yet scanned
for the scan page and lists the last two, by the same sidecar rules the scraper uses
(`sidecar_info`, `is_unmatched`, `unmatched_reason` in `model_identity.py`); it also
lists the model files in other formats, which scans never read, so the page can say so.
lists the model files in other formats, which scans never read, so the page can say so,
the models set to "don't scan", and the new ones whose header names no drawing model kind.
It keeps the last summary (memory and user/anomalous/scan_summary.json) for the page to
show at once, shares one count among the requests meanwhile, and lists the models found on
Civitai on request (`GET /anomalous/scan_matched`).
`api/model_heads.py` keeps what each model file's header says (model_kind.inspect_file)
in user/anomalous/model_heads.json by real path, size and time, so a header is read once per
file version, also across restarts; get_metadata reads headers through it.
Models in component folders (VAE, text encoders, CLIP, CLIP Vision, upscalers) are counted
and listed apart as well (`components`, `component_models`): many are not on Civitai.
It also lists every model a scan reads for the scan settings' picker
(`/anomalous/scan_pickable`), with size, cover and the card badge's metadata fields.
`api/scan_choice.py` saves that choice (`POST /anomalous/scan_choice`, this computer only)
into each model's `<model>.anomalous.json`.
- `api/mcp_server.py` serves `/anomalous/mcp`, the Model Context Protocol endpoint AI apps
connect to (Streamable HTTP; the 2026-07-28 stateless protocol and the earlier
`initialize`-based ones; this computer only, Host and Origin checked against DNS
rebinding). `api/mcp_tools.py` holds the reading tools (summarised views over the same
stores the pages use: models, scans, output images, combos, recipes, saved prompts,
generated audio, activity log) and `call_tool`; `api/mcp_actions.py` the acting ones:
canvas actions done by the open page, scans and Anomalous TTS speech through this
server's own routes. `api/mcp_bridge.py` hands a canvas action to the page over
ComfyUI's websocket and waits for its answer; `web/modules/mcp_bridge.js` is the page
side, doing it with Anomalous's canvas code (one Ctrl+Z step each, logged as the AI's).
User guide: `docs/guides/mcp.md`.
- `api/backup.py` is Settings → Backup (`/anomalous/backup/*`): one .zip of the user's stores
(recipes, combos, materials, parameters, optionally ComfyUI's saved workflows), the plugin
settings without the Civitai key, and each model's user layer and own cover; putting it
back adds what is missing, replaces differing files only on request (Recycle Bin first)
and finds models again by SHA-256, else by folder, path and size. Its model list (every
model's folder, path, size, SHA-256, link) names the models a new computer lacks.
`ui_backup.js` is its export and import dialogs; `ui_backup_downloads.js` downloads the
lacking models again through Model Check's downloads, into the path they had.
- `api/lora_info.py` reads a LoRA's safetensors header to tell whether it trains the text
encoder (`/anomalous/lora_info`), so inserting it wires the CLIP line only then.
- `api/model_download.py` downloads the models a workflow misses (Model Check's
"Download", `/anomalous/download/*`): one at a time into `<file>.part`, continued after
an interruption, checked against the SHA-256 before it gets its name, never over an
existing file; also where downloads go by default (`download_settings.json`).
`api/download_sources.py` finds the one exact file: Civitai by hash, a Civitai version
link, or a Hugging Face / GitHub file link the workflow carries, else a file of the same
name in ComfyUI-Manager's model list (`api/manager_catalog.py`), checked by the hash.
`api/hf_card.py` gives a model downloaded from Hugging Face its model card's example image
as cover and its trigger words and example prompts as notes (never over the user's).
- `api/model_import.py` puts model files the user drops on the models page into the right
models folder (`/anomalous/import/*`): finds the dropped file in this computer's Downloads
or Desktop and moves it (or uploads a copy), tells what it is, checks it against Civitai
and the scanned models, never replaces a file (" (2)").
- `api/model_placement.py` is the models page's Tidy check (`/anomalous/placement/*`): models
whose header names another type than their folder, identical files (by SHA-256), and moving
a model with its sidecars to where it belongs.
- `api/model_usage.py` is Tidy's "not used lately" (`GET /anomalous/usage/check`, this computer
only, on a press): when each model and node pack was last used, from the run record, the
PNG outputs' prompts (image_search's cached records, which keep the node types too), FLAC /
Ogg sounds' prompts and the saved workflows, recipes, combos and parameter sets; nothing that
came in the last 30 days is judged. Node packs come from ComfyUI's loaded packs, with whether
they add to the page and which other packs take what only they make. Read with limits,
deletes nothing. `POST /anomalous/usage/keep` (this computer only) keeps the packs the user
says they still use, which no record can show, in `user/anomalous/usage_kept.json`.
- `api/usage_ledger.py` is the run record behind it: ComfyUI's on_prompt hook (registered in
the plugin's `__init__.py`) notes each queued run's node types and model files with the time,
and `POST /anomalous/usage/opened` (this computer only, capped lists) what an opened workflow
holds, apart from the runs, in `user/anomalous/usage_ledger.json`, saved a few seconds later;
it never stops a run.
- `api/workflow_schema.py`, `api/recipe_schema.py`, `api/recipe_images.py`, and
`api/recipe_store.py` own recipe validation/shaping, images, CRUD, history, and
integrity receipts; `api/recipes.py` is the HTTP facade. `api/recipe_constants.py`
@@ -114,13 +183,16 @@ covered by the `styles.css` manifest.
`api/node_material.py` saves one canvas node's current values as a
`node_parameter_selection` material (the current-node panel's "save").
- `api/local_guard.py` decides which requests only this computer may make (every change under
`/anomalous/`; other devices may look), as one middleware; see backend.md.
`/anomalous/`; other devices may look), as one middleware, and lets other devices change
things too when the server has an `allow_remote_changes` file (cloud servers); see backend.md.
- `api/media_routes.py`, `api/gallery_routes.py`, `api/translation_routes.py`, and
`api/folder_types.py` own the formerly mixed utility route families.
- `model_policies.py` owns shared backend rename and protected-category policy.
- `model_identity.py` owns file SHA-256 evidence and the base model a safetensors header
tells, shared with the standalone scanner (which runs as its own process, so the API
never imports `scraper.py`).
never imports `scraper.py`). `model_kind.py` reads a .safetensors / .gguf header (never
formats that can carry code) and says which models folder the file belongs in and its base
model; model import and the scanner's offline base model use it.
- `recycle_bin.py` is the only way the plugin deletes the user's files (models, covers,
outputs, recipes, materials, notes, audio): to the system Recycle Bin, together, or not
at all (`TrashUnavailable` on drives without one). `api/trash.py` is the API's import of it
@@ -150,6 +222,32 @@ covered by the `styles.css` manifest.
switch refuses to run over modified tracked files. `ui_version_manager.js` is
the version line (shown in the update guide opened by the header "!" button) and panel; nothing goes online until "check for updates"
is clicked, and restarts go through ComfyUI Manager's reboot route when present.
Every function takes an optional `Checkout` (another folder and its own state
file): `api/tts_version.py` uses it for the companion node pack Anomalous_TTS,
found among the custom_nodes folders by its node class and accepted only as a
git clone whose `origin` is the official repository (`/anomalous/tts_version`,
`/releases` local-only, `/update`, `/undo`; last switch in the ignored
`.anomalous_tts_version.local.json`). `ui_tts_version.js` is its group in the
GPT-SoVITS settings dialog: installed version, "Check for updates", update to
the newest stable release or back, then the restart question.
- Generation presets (Anomalous_TTS interface 16): `tts_presets_api.js` holds the
calls (`/anomalous_tts/presets`) and pure helpers (the language pairs the
characters can meet, the presets file written back with only what differs from
the built-in values plus the presets made here (`custom_<id>` with a label),
where a preset is used and taking it out, a dialog row's numbers in effect
next to the general ones, a character's `defaults` / `defaults.by_language` as
rows and back, keeping every other field, where a mode sits between steady and
lively, a line to listen with in each language). `ui_tts_preset_switch.js`
draws the modes as choices and the picked one against one fixed mode (the
default preset, or Auto): a steady-to-lively scale, a sentence, and folded, the
four numbers as the Current node card's bars, each with its plain name and the
node's own name (`top_k` …), editable in place. `ui_tts_mode_preview.js` makes
and keeps the takes for listening (one line, one seed, only the mode changing,
through Preview Audio). `ui_tts_presets.js` is the group in the GPT-SoVITS
settings dialog (that switch with a character and a line to listen with, make
default / reset / new / rename / delete, rules per pair), saving each change at once; `ui_tts_tuning.js` is the
per-character dialog behind the card's Generation settings button, shown only
with interface 16.
- `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared
browser class and extracted-method wiring; `browser_entry.js` owns the single
@@ -186,8 +284,11 @@ covered by the `styles.css` manifest.
and `invalidateEngineCache({ rescan: true })` (Refresh button) also makes the node
re-read its folders (`?refresh=1`).
`ui_audio_tts_editor.js` edits a character's emotion references through that API:
it opens at once, loads the character's audio list in the background (save waits
for it), and keeps settings fields it does not know.
it opens at once, loads the character's audio list and their lines
(`/anomalous_tts/reference_lines`, interface 14; optional) in the background (save
waits for the list), and keeps settings fields it does not know.
`ui_tts_clip_search.js` is its clip search: by file name or line, the first 30
matches with listen, use as main voice and add as emotion.
`tts_setup_api.js` holds the other calls (pretrained downloads, chunked
upload, inspect, commit, discard) and the pure import-form rules (`importKind`,
`pickWeights`, `nameConflict`, `textFromFile`, `buildImportBody`, `importProblem`,
@@ -269,13 +370,19 @@ covered by the `styles.css` manifest.
dragged onto one (writing the character and script widgets together, one Ctrl+Z
step) or copied. The page feeds it the voice groups after each fetch.
It puts the cards on one side and generating on the other: beside
them once the page is wide, below them when it is narrow (docked browser).
them once the page is wide, below them when it is narrow (docked browser); on a
wide page `ui_split_bar.js` is the divider between them (dragged, remembered).
A card is one row (number, text, buttons); "Into your own workflow" is folded.
`ui_script_run.js` is the director's "Generate" section (the bar with the result and
a way to the audio gallery; language and speed in view, the sampling numbers folded
under "fine-tuning" with plain names): it runs
a way to the audio gallery; language, speed and the mode: Auto, or one preset for
every line through the node's `preset` input, in the same switch measured
against Auto (compact: scale and numbers folded) and heard from the ▶ on a
line card (the takes heard stay on the side to compare), a preset's numbers
changed there saved into the preset; links to the presets and to the
character's generation settings): it runs
the script without the canvas, plays the result, retakes the whole script
(new seed) or one line (`[take:N]`, the node caches the rest), and saves a
character's language, speed and folded sampling parameters to its `defaults`
character's language and speed to its `defaults` (keeping the rest of them)
(only values that differ from the node's own defaults, which it reads from
`/object_info`; needs Anomalous_TTS interface 11).
`ui_tts_pronunciation.js` edits a GPT-SoVITS character's pronunciation table
@@ -300,13 +407,14 @@ covered by the `styles.css` manifest.
copied into `output/audio/`.
- `ui_sidebar.js` assembles the browser window (rail, list column, header, page panels;
`remote_view.js` adds the header's "view only" chip when opened from another device)
and renders the model folder list. `ui_shell_rail.js` owns the left icon rail: one
and puts the model folder column in the list column (`ui_folder_list.js`: the folders of the
type the chips show, counts with subfolders, 📂 to open one in Explorer). `ui_shell_rail.js` owns the left icon rail: one
entry per page, the tool slots and the settings slot. `ui_shell_nav.js` owns page
navigation (`goTo`): the domain each page needs, which pages have a list and whether
it is open (remembered per page; closed and overlaying below 760 px), the header's
page title, and the page reopened next time. `ui_home.js` owns the home page (task
cards, first steps). `ui_shell_frame.js` owns dragging, resizing and keeping the
floating window on screen; `ui_scan_watch.js` polls scan status for the rail's scan
floating window on screen, and the full-window button (double-click on the header too); `ui_scan_watch.js` polls scan status for the rail's scan
button, the progress panel and the model reload afterwards.
The activity log (docs/decisions AD-018): `api/activity_log.py` keeps the entries
(newest first, bounded, in the ComfyUI user folder), records this plugin's and
@@ -322,26 +430,40 @@ covered by the `styles.css` manifest.
removed nodes or rewired existing ones are never offered).
`ui_settings_hub.js` owns the display preferences (view mode, scale, atmosphere, window
layout) and their application, the gear and language redraws; `ui_settings_page.js`
is the settings page the gear opens (a tool page with Back): look and language, model
cards and memory with the card image cache, folders, workflow fingerprints, opening
mode and window, help; `ui_feedback_dialog.js` is the feedback window (Home and the
is the settings page the gear opens (a tool page with Back), in tabs: Look (look and
language, model cards and memory with the card image cache, opening mode and window),
Models (folders, where downloads and imports go), Workflows (fingerprints), Node packs
(those not used lately, `ui_pack_usage.js`), Prompts
(translation), Backup (`ui_backup.js`), Help; `ui_feedback_dialog.js` is the feedback window (Home and the
settings page open it): one text box, then `feedback.js` opens a GitHub issue with it in
the browser's language, the environment folded at the end when attached (versions and
hardware only, never a path or ComfyUI's command line), or copies the environment;
hardware only, never a path or ComfyUI's command line), or copies the environment, and
`openRepoPage()` opens the project page for a Star (on a press only; nothing is fetched);
`ui_rail_tools.js` owns the rail's tool buttons (scan, doctor, current node);
`ui_browser_navigation.js` owns shared panel hiding/cleanup and workspace return,
including Esc on the workspace panel (`nbPanel`, below the header and right of the
rail; the list column steps aside while it is open).
`ui_scan_page.js` is the scan page (a tool page like the doctor): counts, the scan
button, a card for how the next scan goes (opening the scan settings page), the last
scan's summary; each count and the summary open a
`ui_scan_page.js` is the scan page (a tool page like the doctor), quieter the further
down: the scan card (a band of covers with an empty frame per model not scanned yet, what is left, the scan button with "pick
models to scan" beside it, online or offline, what the scan will do in one sentence), the
models' state as a bar and chips (the components on a chip of their own), what needs a look,
the settings (`ui_scan_settings.js`: the scan options as tiles, the two that change files
folded under "more"), the last scan in one line; icons from `scan_icons.js`. Each state chip,
the components and the last scan open a
list page from `ui_scan_lists.js` (not scanned, unmatched, other formats, the last scan's
models) with Back, a button for the whole list (scan these / look them up again) and rows
that open a model, whose Back returns to the list, or scan it;
`scan_results.js` holds the words for scan results, shared with the activity page.
`scan_results.js` holds the words for scan results, shared with the activity page;
`net_failure.js` the words for why Civitai could not be reached (net_proxy.py's codes), for
the scan results and Settings → Network's connection test.
`ui_scan_picker.js` is the scan page's model picker: every model a scan reads, by folder,
state (not scanned, not found, damaged, found, don't scan) and search, as a list or covers;
the page keeps its picks as items (none: the button scans everything).
`scan_runner.js` starts and follows scans (every folder, picked or listed models, or one
model from its card), shows the result and refreshes node drop-downs, hashes and the
grid afterwards; folder visibility/order lives in
grid afterwards; before an online scan `scan_connection.js` checks whether Civitai can be
reached and, when not, asks: scan from the files, set a proxy, or not now;
folder visibility/order lives in
`ui_folder_manager.js`.
`scan_progress.js` owns the scan progress panel
(`updateScanProgress` / `finishScanProgress` / `failScanProgress`): inside the scan
@@ -350,15 +472,40 @@ covered by the `styles.css` manifest.
shortcuts (the second keeps its old "materials" ids), their fallback when ComfyUI's keybinding does not fire, and the
settings control that opens ComfyUI's keybinding editor.
- `ui_model_sources.js` renders the Models page's Sources view (the last type chip): where each model is downloaded, for the open workflow or every model, with each link editable and saved in place; `model_source_links.js` owns its data and actions (collecting the workflow's models, resolving them here, saving a link to the model's information and the workflow, the canvas note and the clipboard list).
- `ui_gallery_card.js` builds one output image's gallery card (viewer, workbench, cover pick, drag, star, delete);
`gallery_card_menu.js` is its right-click menu (insert as a Load Image node reading the output file, parameters);
- `ui_gallery_card.js` builds one output's gallery card (viewer, workbench, cover pick, drag, star, compare, delete; a video's card
plays in the viewer and has no workbench, star or menu); `gallery_filter.js` holds what the gallery lists (All / Pictures /
Videos and the formats hidden in Settings, per browser) and `ui_gallery_filter.js` draws both controls;
`gallery_card_menu.js` is its right-click menu (insert as a Load Image node reading the output file, parameters),
drawn by `action_menu.js` (a small menu at the pointer or under a button);
`ui_param_compare.js` compares parameters: a card's ⇄ picks an image, a second image's ⇄ or
the canvas completes the pair (the image workbench's ⇄ takes the canvas; the Gallery's and
Activity's ⇄ Canvas vs. newest picture pair the newest output PNG with it), and its window says
whether both are the same node structure and lists each value that differs (prompts by the tags
only one side has, seeds apart), writing ticked image values onto the canvas as one Ctrl+Z
step with Undo; `param_compare.js` reads and compares the two sides without DOM.
`key_params.js` keeps the starred key parameters (node type + widget name, a hidden ComfyUI
setting); the window shows them first. `ui_param_set_keys.js` puts parameter sets in view: a
recipe card's ▤ N chip (first in its specs line) (`GET /anomalous/parameters/signatures`; sets equal to the recipe not counted) opens its Parameters tab,
and each set there shows its key values, how many values differ from the recipe, and ⇄.
`ui_keep_menu.js` is the star's keep menu (also the workbench's Keep): the whole workflow, the combo or the prompts,
each saying where it goes, opening what was kept already or removing it (to the Recycle Bin through the recipe,
notebook or material delete route); `image_keep.js` does the keeping (an image as a combo,
its prompts as a saved prompt, reading what was kept) without DOM.
- `ui_combos.js` renders the Combos page's list (搭配: a main model, LoRAs and a prompt, formerly Prompt Notes): search,
New, and a card per combo with its model's cover and Put on canvas; a card opens the combo's editor, and dragged onto the
? (what a combo is), New, and a card per combo with its model's cover and Put on canvas; a card opens the combo's editor, and dragged onto the
canvas (`material_drag.js`) becomes a new group of nodes where it is dropped. Combos keep the notes' files.
A combo can instead hold a node structure (`data.kind === 'nodes'`): `combo_structure.js`
captures the canvas's picked nodes (subgraphs aside) with the links between them and puts
them back (other boxes stored by name and restored untouched, slots = model drop-downs and
text boxes, the folder a drop-down lists read from ComfyUI's own `/models` lists, inputs fed
from unpicked nodes noted to wire);
`combo_slots.js` holds its rules that need no canvas; `ui_combo_structure.js` is the save
dialog, the slot editor, the slot model picker and "New"; `ui_translation_peek.js` is the
read-only translation panel beside its prompts. `lora_nodes.js` knows the LoRA nodes a default
combo puts its LoRAs in (ComfyUI's chain, rgthree's Power Lora Loader, Easy-Use's LoRA stack, the
third-party ones only when installed) and a Power Lora Loader's rows (for structures, and as
drop-down-like boxes for Model Check); `ui_lora_node_choice.js` is the default combo editor's
choice among them.
- `detached_graph.js` lays a workflow on a graph of its own, off the canvas, under an id of its own: ComfyUI keeps widget
values by graph id, so a copy under the canvas's id would write its values onto the canvas.
- `recipe_save.js` saves Workflow Recipes for the canvas save and for an output image's workflow (the gallery's keep menu,
@@ -366,42 +513,79 @@ covered by the `styles.css` manifest.
- `ui_apply_receipt.js` is the receipt of values written to a node from a panel (Current node's parameters) with its Undo. `node_material_actions.js` owns prompt envelope extraction (`extractMaterialPromptEnvelope`) and the node writes shared by those panels and prompt drops.
- `ui_update_guide.js` and `update_guide_data.js` own the non-intrusive update guide modal (accessible via header button `#anomalous-update-notice-btn`, the Help modal and Home's "What's new"; the current guide's ID and steps live in `update_guide_data.js`) with full bilingual localization. `ui_spotlight_tour.js` provides the interactive spotlight mask tour (`startSpotlightTour`), gliding smooth focal box highlights across the rail's pages and tools with directional tooltip cards and keyboard navigation; steps whose target is not on screen are skipped. The browser tour goes in the order the pages are used (rail, Gallery and what its ☆ keeps, Workflows, Combos, Prompts, then the tools), with its text in `locales.js`. Other views pass their own `steps` (text from locale keys) and an optional `onClose`; the GPT-SoVITS import window does.
- `tool_registry.js` holds the tool icons shared by the rail and Home.
- The rail's tool slots (`ui_rail_tools.js`, fixed) hold scan, doctor and current node; settings sits at the rail's bottom. The rail's Prompts entry opens Prompt Studio beside the canvas (the browser folds away and comes back when it closes). There is no toolbox: the other tools open from their pages (workflow share codes on Workflows, Model Sources as a view of Models, opened from the doctor too, translation in Current node).
- The rail's tool slots (`ui_rail_tools.js`, fixed) hold scan, doctor and current node; settings sits at the rail's bottom. The rail's Prompts entry is Prompt Studio's page (`ui_prompt_composer.js`); its "Beside the canvas" moves the studio into a drawer by the canvas with the browser folded away, and "Back to the window" returns it. There is no toolbox: the other tools open from their pages (share and import on Workflows, Model Sources as a view of Models, opened from the doctor too, translation in Current node).
- `ui_share.js` is share and import (the Workflows page's ⇅): the canvas workflow as a share
code in Chinese characters or letters, and one place to paste a code or workflow JSON or drop
a workflow file, an image, a recipe package or a backup .zip. `ui_recipe_package.js` exports
a recipe package from a card or the detail's More menu and takes one in as a new card. `share_code.js` is the AMB2 code (lean workflow
checked by rebuilding it, else the whole workflow; AMB0/AMB1 still open); details in
`docs/architecture/frontend.md`.
- `ui_model_types.js` owns the models page's type chips (one per models folder, with its count) and
`owner.modelScope`, what the grid lists: a whole type, or one list folder shown as a crumb. The
grid's cards set `currentType/PathIdx/Subfolder` to their own model's folder (`focusModel`),
which the editor, the scanner and "add to canvas" read.
which the editor, the scanner and "add to canvas" read. Its Import chip and files dropped on
the grid open `ui_model_import.js`, the import window (one card per file: type, base model,
where it goes, warnings, progress); `model_import.js` reads each file's header and asks,
moves or uploads it, then scans the new files. The page's tabs (All models, Tidy with how
many things it found, Sources) head the grid; under All models the type chips and the base
model chips (`owner.modelBase`; `model_bases.js` names each model's family from its scanned
base model or its header's guess). The Tidy tab is `ui_model_tidy.js` (`owner.modelView ===
'tidy'`): cards with covers in two steps — models in a folder whose loader cannot read them
(from → to, Move there), identical copies (pick the one to keep, recycle the rest);
`model_placement.js` does its requests and keeps the tab's count. Under the two steps
`ui_model_usage.js` lists the models not used for 30 / 90 days or never (picked ones go to
the Recycle Bin) and leads to Settings → Node packs, where `ui_pack_usage.js` lists the node
packs: each opens ComfyUI Manager's node list searched for it (Manager 3; its name is copied
for the rest); packs that also add to the page are set apart, those marked "I still use it"
listed last. `usage_view.js` is what both share: the check, kept in `owner.modelUsage`, and
each page's head (groups, what the run record vouches for). `usage_notes.js` posts what each workflow ComfyUI opens holds (its own
`loadGraphData` wrapper, once per content and page load) and the open tabs just before a
check, so opening a workflow or putting a node on the canvas counts as use. `ui_model_search.js` is the models
search in the header (shown on the models page through the shell's `data-page`): the grid
lists the models of its type matching every word (`owner.modelQuery`).
- `model_source.js` shows where a model's information came from: the card badge (marked only
when inferred from the file, ≈, or not scanned yet) and the detail header's source line with
the fields the user set.
when inferred from the file, "推断", or not scanned yet), the details' source line with
the fields the user set, its links (source link, Civitai) and, for a model Civitai
does not know, the note on what was read from the file.
- `ui_grid.js` and model-detail modules own model presentation: `ui_grid.js` manages chunked card rendering,
card placeholder ergonomics (eliminating misleading unclickable text in favor of pure centered icon and status),
card action buttons (one-click canvas addition with plus icon, model metadata editor, direct precision scanner without wizard modal popups)
with absolute positioning cascades immune to tooltip target conflicts, vibrant hover affordance,
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_detail.js`
coordinates detail display (the file card with Show in folder in `model_location.js`), `ui_model_editor.js` owns metadata editing, and
`ui_model_selector.js` owns advanced selection. `ui_gallery.js` (cards in
`ui_gallery_card.js`) and `ui_gallery_detail.js` own generated-image browsing and workbench lifecycle,
coordinates detail display (cover column, then the head from `model_detail_head.js`: back crumb, name, source line,
Add node / Edit / Folder / Outputs; the foot with the path, Copy and Delete, and Show in folder, in
`model_location.js`), `ui_model_editor.js` owns metadata editing (cover, name, source link, notes;
styles `styles/31-model-info.css`), and
`model_widget_path.js` puts a model path into a loader's widget. `ui_gallery.js` (cards in
`ui_gallery_card.js`, filters in `gallery_filter.js` / `ui_gallery_filter.js`) and `ui_gallery_detail.js` own generated-output browsing and workbench lifecycle,
with stage interaction in `ui_image_stage.js` and metadata tabs in
`ui_image_inspector.js`.
- `ui_recipes.js` / `ui_recipe_detail.js` and `ui_notebooks.js`
own their respective workspace surfaces and persistence flows. `ui_recipes.js` owns
the Workflow Recipe studio catalog workspace with search/filter tags, grid/list layout toggle,
dedicated top-right modal close anchor (permanently decoupled from the tool button row to prevent wrapping displacement),
streamlined action header (preserving active workflow saving while pruning unfinished package
import entrypoints), and card browsing. `ui_notebooks.js` owns the Combos workspace (its list or one combo's editor,
own their respective workspace surfaces and persistence flows. `ui_recipes.js` hands the
Workflows page (its catalog) the recipe services (save, edit, fetch) and opens a recipe by name. `ui_notebooks.js` owns the Combos workspace (filling the page; its list or one combo's editor,
with Back to the list) and combo persistence; `ui_notebook_editor.js` owns unfolded card editing (modularized into single-responsibility
sub-functions adhering to the 50-line rule: sticky top action toolbar with floating More popover dropdown and timed two-step delete safety guard,
unfolded companion models card with unconstrained multi-column tile flow eliminating nested gallery scrollbars,
prompt composer with dynamic field-sizing and compact inline find & replace toolbar, and unified dark slim scrollbar ergonomics with complete bilingual dictionary coverage in `locales.js`), and
`notebook_canvas.js` puts a combo on the canvas as a new wired group of nodes (following the pointer, or at a dropped
card's position; Esc takes a following group off again); it never changes nodes already there. Combos are a rail page. `ui_recipe_detail.js`
coordinates the Workflow Recipe detail session and model composition. `ui_recipe_overview.js`
owns the Overview prompt showcase (with `entry.text` fallback, guarded non-shrinking primary action CTA, and floating Popover More dropdown menu), and `ui_recipe_parameters.js`
owns the responsive Parameter Presets workspace (featuring default-expanded raw node parameter inspection,
a `clamp(230px, 24vw, 290px)` sidebar with guarded card actions, uncluttered console action bars with deferred status feedback,
`minmax(130px, 1fr)` Bento Grid with universal click-to-copy, LoRA cards with flexbox truncation guards,
and sticky editor headers).
is a workflow's details, built around its values (styles `styles/29-workflow-detail.css`): a bar
(Back, the name, the models' state, Open, ⋯ edit and export), then `ui_recipe_sets.js`'s left
column (the workflow's own values, the saved sets unlike them with their key values, Save the
canvas, New; then all pictures and versions) and the chosen view on the right.
`ui_recipe_set_view.js` is one set: Apply to canvas, ⇄ (with the workflow's own or the canvas),
⋯ (copy as a new set, rename, delete); the cover, notes and tags for the workflow's own
(`ui_recipe_info.js`, which also holds the name and the models' state); the workflow's focus
values (重点参数: `recipe_focus.js` keeps them in the recipe's params and tells comparisons which
values they are, `ui_recipe_focus.js` draws them with their notes and the ☆ on each node
value); the main values; the
prompts; the pictures made with these values; the models (the fold says whether all are here)
and every node's values, folded, each opening as the viewer last left it (this browser's
storage); a tip while the workflow has no set, and one pointing at ⇄ on a set until it is used.
Narrow (docked) details turn the left column into one row of pills (a container query). A new
set is named after the workflow with the next number (`nextSetName`). `ui_recipe_parameters.js`
holds what the views share: prompt roles (automatic or set by hand) and their boxes, every node's
values (unnamed ones labelled by their node type's widgets, `comfyWidgetLabels`), the new set's editor.
`prompt_boxes.js` is the one place that finds prompt boxes on the live canvas: a
multiline STRING input by the node's ComfyUI definition, whatever it is called; its
role is the box's own name (positive / negative) or else the wiring (outputs followed
@@ -413,20 +597,31 @@ covered by the `styles.css` manifest.
extracts prompt envelopes without model file paths and writes them (`fillPrompt`);
text never crosses roles unless the user picked the box; writes are one undo step.
Within recipe detail, `ui_recipe_versions.js` owns history comparison/restore,
`ui_recipe_gallery.js` owns result cards and direct Image Detail Workbench handoff,
`ui_recipe_gallery.js` owns the picture grid (thumbnails, 参数, ⇄ to compare) and the all-pictures view,
`ui_recipe_models.js` owns the overview's model list (preview, presence under the saved name,
note, download page; finding missing models is Model Check's) and the recipe cover,
`ui_recipe_metadata.js` owns inline persistence, and `ui_recipe_detail_dom.js` owns
the DOM/copy helpers shared by detail subviews. `recipe_identity.js` derives model
references from native loaders plus a table of verified all-in-one loader layouts
(`ALL_IN_ONE_LOADER_SPECS`, mirrored in `api/recipe_schema.py`); other third-party
widgets stay parameters. References are keyed by
(`ALL_IN_ONE_LOADER_SPECS`) and two stack nodes by their rows (rgthree's Power Lora Loader,
Easy-Use's LoRA stack; `nodeModelValues`), all mirrored in `api/recipe_schema.py`; other
third-party widgets stay parameters. References are keyed by
`(node_id, widget_index, category, saved_value)`. `recipe_parser.js` summarizes
models, LoRAs, samplers and prompts (linked prompt nodes before embedded loader
prompts) and keeps the summary in step with widget edits using the same node rules.
`ui_recipe_catalog.js` owns recipe
filters, navigation, dismissible topbar drag guidance strip with localStorage persistence, the 3-step empty-state onboarding blueprint (`renderRecipeEmptyGuide`), and background catalog-wide model readiness resolution (`resolveCatalogRecipeReadiness`), `ui_recipe_cards.js` owns cards and card actions
(including `grab` drag affordance, cover `可拖拽` badge, harmonized multi-state model readiness pill with `getRecipeReadiness` synchronizing available, missing, and pending matches with detail overview, and direct canvas drag-and-drop), `ui_recipe_dialogs.js` owns save/edit dialogs, and `ui_recipe_media.js` owns shared cover helpers. Detail sessions synchronize detected model availability back to `owner.recipeRecords` via `syncRecipeReferencesToCatalog`.
prompts) and keeps the summary in step with widget edits using the same node rules; which
LoRAs a node loads is `recipe_loras.js`, and reading a node's boxes `recipe_widgets.js`.
`ui_recipe_catalog.js` is the Workflows page: one bar (search, the filter menu with kind and tag,
the count, grid or list, share and import, save the canvas), the list, the empty page's three-step
guide (`renderRecipeEmptyGuide`) and the catalog-wide model readiness check
(`resolveCatalogRecipeReadiness`). `ui_recipe_cards.js` is one card: the cover with the models'
state (`getRecipeReadiness`: a dot, or words when some are missing) and 功能模块 for a partial
workflow, the name, a line of steps, sampler, LoRAs and size, then its model; Open and ⋯ (edit, export, delete)
as two small buttons in the cover's corner on hover; a press anywhere else opens the details, a drag opens it on
the canvas. Opening a workflow on the canvas hides the browser and leaves the Workflows list in place. Its styles are
`styles/28-workflows.css`. `ui_popup_menu.js` is the small menu under a button they share,
`ui_recipe_save_dialog.js` is saving the canvas as a workflow (the cover chosen on the left from the canvas,
the current cover or recent pictures as thumbnails; name, tags, notes and the fingerprint choice on the right;
the name defaults to the canvas's saved workflow name), `ui_recipe_dialogs.js` the edit dialog, and `ui_recipe_media.js` owns shared cover helpers. Detail sessions synchronize detected model availability back to `owner.recipeRecords` via `syncRecipeReferencesToCatalog`.
- `ui_prompt_composer.js` owns the standalone Prompt Studio drawer (dock side, width, Esc);
`ui_prompt_workbench.js` fills it: the top bar (tags or text view, the meanings' language) and
saving a box as a card. `ui_prompt_target.js` decides what is edited, the prompt boxes of
@@ -437,7 +632,9 @@ covered by the `styles.css` manifest.
`prompt_gloss.js` looks up and keeps the tags' meanings in the picked language. `ui_prompt_source_deck.js`
owns the card list (three built-in cards and the saved prompts), search, new-card form,
library sync and renaming / deleting a saved prompt; `prompt_material_source.js` reads
saved prompts as cards and saves one; `ui_prompt_card_popover.js` owns the card preview (hover
saved prompts as cards and saves one; `ui_prompt_reference.js` is the reference column a
clicked card opens in, between the cards and the boxes (its tags taken one by one or all, ticks
on those the box has; it widens the drawer while open, and takes the cards' place where three columns do not fit); `ui_prompt_card_popover.js` owns the card preview (hover
corridor, pin, Copy / Add / Rename / Delete); `prompt_card_drag.js` lets a card or a box be
dragged out of the drawer onto a canvas prompt box or empty canvas (through `material_drag.js`
and `prompt_drop.js`), passing through inside the drawer so the boxes still take it.
@@ -447,7 +644,8 @@ covered by the `styles.css` manifest.
cancellation and resize cleanup. Translation requests go through `translation_service.js`.
- `ui_dialog.js` owns the plugin's own alert / confirm / prompt dialogs
(`anomalousAlert`, `anomalousConfirm`, `anomalousPrompt`), used instead of the
browser's native ones. `ui_prompt_toast.js` is the short toast (optionally with an
browser's native ones; an alert can carry a line to copy (`copy`), and `copyText`
also works on a page served over plain http. `ui_prompt_toast.js` is the short toast (optionally with an
action, such as Undo after a drop) shared across the plugin.
- `canvas_history.js` asks ComfyUI's Ctrl+Z history for a step after each canvas write
Anomalous makes (applied values, drops, created or inserted nodes, recipes): ComfyUI
@@ -457,17 +655,31 @@ covered by the `styles.css` manifest.
need a workflow-carried hash before Model Check recovers them.
- `ui_dom.js` provides small DOM/JSON helpers; `material_inspector.js` owns the image
workbench's metadata and node-parameter rendering.
- `model_check.js` checks the open workflow's models against this computer (Model
- `ui_first_run.js` is Home's first-run setup while nothing is scanned (check that Civitai answers
through `api/network_check.py`, the optional key, a scan of every model, the tour); a card, never
a popup, hidden per browser.
- `model_check.js` checks the open workflow's models (drop-downs naming model files, and
switched-on Power Lora Loader rows through `lora_nodes.js`) against this computer (Model
Check's verdicts, no DOM; the feature was called Model Doctor, and code and CSS still
say "doctor") and puts a found file into its node; `ui_doctor.js` is the
Model Check page and `ui_doctor_banner.js` the bar over the canvas when an opened workflow
misses models (it also owns the check-on-open preference that Settings → Workflows and the
bar's "Don't show again" set). `ui_node_assistant.js` owns the current-node panel (model actions, LoRA
bar's "Don't show again" set). `model_download.js` finds a missing model's source,
decides where its file goes (the download settings, `{base}` = its base model's folder),
follows the downloads and puts each finished file into its nodes (the placement rules
are `download_places.js`, without imports so they are tested alone); `ui_model_download.js`
is the page's Download buttons, progress and the dialog that shows and changes where
each file goes. `ui_node_assistant.js` owns the current-node panel (model actions, LoRA
insertion, model cards and history), `ui_node_model_picker.js` owns native combo
replacement, `ui_node_parameters.js` renders the panel's parameters section, and
replacement, `ui_node_parameters.js` renders the panel's parameters section (each saved
set as a card with the picture made with it and where it came from, values as bars or
old → new, or all sets side by side), and
`node_parameter_sets.js` merges the node type's saved values (material files and recipe
parameter sets) and works out what each would change on the node, keeping seeds,
model files and missing choices; saved values (not a recipe's) are deleted there. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js`
parameter sets, each source with its origin) and works out what each would change on the node, keeping seeds,
model files and missing choices, and asks `/anomalous/parameter_pictures` for the recent pictures made with
the sets' values; saved values (not a recipe's) are deleted there. It also keeps, in this
browser's storage (`anomalous_applied_sets`, the newest 100 by workflow and node), which set
was last put into a node, for the panel's line on where the values came from. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js`
own the remaining explicit graph changes.
- A model's download link lives in its user layer (`source_url` in `<model>.anomalous.json`, via
`/anomalous/update_metadata`), the same field the model editor edits; for the open workflow's
+237
View File
@@ -1,5 +1,242 @@
# 📈 Anomalous Model Browser Changelog
## 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
### 🔍 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 (画布不再被其他插件拖慢)
-5
View File
@@ -1,5 +0,0 @@
# Claude 工作入口
本仓库的开发与维护规范以 AGENTS.md 为唯一正文,第 0 节是每次提交都必须做到的要求;这里只做入口,不另写规则。
@AGENTS.md
-11
View File
@@ -1,11 +0,0 @@
# Gemini 工作入口
在本插件中修改任何文件前,先阅读并遵守根目录的 [AGENTS.md](AGENTS.md)。它是本仓库开发与维护规范的唯一正文,适用于 UX 调整、修复和重构;第 0 节是每次提交都必须做到的要求。
随后阅读 [ARCHITECTURE.md](ARCHITECTURE.md),按其中的阅读地图加载与当前任务有关的专题。
不要只凭当前任务描述直接追加实现。先确认现有责任模块、状态来源、关闭清理和样式规则;完成后按规范验证并报告结果。
如果当前工具没有加载本文件,任务发起者应明确要求 Gemini 阅读 `GEMINI.md` 和 `AGENTS.md`。文件的存在不代表工具已实际读取,更不代表检查已自动执行。
前端设计任务按 AGENTS.md 第 4 节和 [前端架构](docs/architecture/frontend.md) 进行,不使用历史视觉建议作为当前任务单。
+120 -84
View File
@@ -9,9 +9,10 @@
[![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.58.3_Beta-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/BV1a1bv68EuA/)
[![YouTube Video](https://img.shields.io/badge/YouTube-Video_Demo-red?style=for-the-badge&logo=youtube)](https://youtu.be/hAvsj7uiaCw)
[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.60.2-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/>
@@ -19,7 +20,7 @@
<br/>
<img src="docs/images/models.webp" width="900" alt="Models with their Civitai covers, base model and real names">
<img src="docs/images/en-models.webp" width="900" alt="Models with their Civitai covers, base model and real names">
</div>
@@ -29,43 +30,56 @@
> **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.
### 🎬 Video Walkthrough & Demos
* 📺 **YouTube**: [Watch Quick Walkthrough on YouTube](https://youtu.be/hAvsj7uiaCw)
* 📺 **Bilibili**: [Watch Video Demo on Bilibili (在哔哩哔哩观看)](https://www.bilibili.com/video/BV1a1bv68EuA/)
### 🎬 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)
### 👀 See it in action
### 💡 What it helps with
*The pictures show the Chinese interface; English is one switch away in Settings.*
**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.
**1. Scan once, then pick models by their covers.** Press **Scan** and every model is looked up on Civitai by its file hash, so even a file called `下载 (3).safetensors` gets its real name, cover, trigger words and base model.
<p align="center"><img src="docs/images/en-scan.webp" width="520" alt="Scan results: each file matched on Civitai"></p>
<p align="center"><img src="docs/images/scan.webp" width="520" alt="Scan results: each file matched on Civitai"></p>
**2. Someone else's workflow opens with red nodes.** Model Check says which models are missing. When the workflow carries model fingerprints (the plugin writes them into the workflows and images of everyone who has it, unless they turn it off), the ones you already have under another name or in another folder go back in with one press, and the rest can be downloaded from Civitai or Hugging Face, each download checked against its fingerprint. A workflow without fingerprints can only be matched by file name: a file of the same name elsewhere on your computer is offered for you to confirm.
**2. Open someone else's workflow without hunting for models.** When models are missing, a bar over the canvas says so. One press puts back the ones you already have under another name or in another folder; the red outlines go away.
<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>
<p align="center"><img src="docs/images/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.
**3. Find an old image and get its workflow back.** Search the gallery by prompt words, model or LoRA, then drag the image onto the canvas.
<p align="center"><img src="docs/images/en-gallery.webp" width="760" alt="Gallery search, then drag an image onto the canvas"></p>
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="Gallery search, then drag an image onto the canvas"></p>
**4. The same model, LoRAs and prompt, again and again.** Pick those nodes on the canvas, right-click → **Save as combo**, and they are kept as a card beside the canvas. Next time press **Put on canvas** (or drag the card) and the wired group comes back where you click; nothing already on the canvas changes. Models and prompts in a combo can be changed later.
**4. Keep a model + LoRA + prompt combo and lay it down in one move.** Drag a combo card onto empty canvas: a wired group of nodes appears, and nothing already on the canvas changes.
<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>
<p align="center"><img src="docs/images/combo.webp" width="760" alt="A combo card dragged onto the canvas becomes 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).
**5. Edit prompts as tags.** Select a prompt node and Prompt Studio opens 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>
<p align="center"><img src="docs/images/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.
**6. See what the plugin changed, and undo it.** Every change the plugin makes is listed in Activity, with **Undo** and **Find on canvas**.
<p align="center"><img src="docs/images/en-voices.webp" width="760" alt="Character voices page"></p>
<p align="center"><img src="docs/images/activity.webp" width="560" alt="Activity log with an undo button"></p>
**Changed your mind?** Everything the plugin changes is listed in **Activity**, with **Undo** and **Find on canvas**.
**7. Give characters a voice** (with [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-activity.webp" width="620" alt="Activity log with an undo button"></p>
<p align="center"><img src="docs/images/voices.webp" width="760" alt="Character voices page"></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**.
### 🌟 What it does
### 📦 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.
@@ -75,41 +89,42 @@ Every page has an icon on the rail at the left of the window.
| **🕘 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. |
| **🪡 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** | Opens Prompt Studio beside the canvas. 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. |
| **✍️ 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. |
Tools on the rail:
* **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 hash and file size); its page lists the rest with what you can do.
* **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**: workflow share codes (import / export) on Workflows, Model Sources (where each model can be downloaded) on Models and in Model Check, translation on the prompt boxes in Current node.
* **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.
**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**.
**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).
### 📦 Quick Installation
</details>
1. Open your terminal in the ComfyUI `custom_nodes` directory:
```bash
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
2. Restart ComfyUI. *(Alternatively, install via **ComfyUI Manager** by searching for `Anomalous Model Browser`)*
3. For character voices, also install [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) the same way. It is optional.
4. **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.
<details>
<summary><b>⚙️ Opening, shortcuts and updating</b></summary>
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).
The first time you open it, start on **Home**: **Scan model folders** fills in covers and trigger words, and **Tour the interface** points at each part of the window.
**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.
> [!NOTE]
> **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). Without them, pick **Offline scan**: base models are read from the files, without covers or trigger words, and a later online scan fills in the rest. Browsing models, outputs, workflows and combos needs no network.
</details>
> [!WARNING]
> **Keep a copy of your own data:** Workflow Recipes, combos, saved prompts and node values, and parameter presets live in `workflows/anomalous_recipes`, `workflows/anomalous_notebooks`, `workflows/anomalous_materials` and `workflows/anomalous_parameters` in your ComfyUI user folder. A copy of them before updating the plugin is a good habit.
<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>
---
@@ -124,41 +139,59 @@ The first time you open it, start on **Home**: **Scan model folders** fills in c
>
> 插件改动过什么,都能在“操作记录”里查到。
### 🎬 视频演示与教程
* 📺 **哔哩哔哩 (Bilibili)**:[在 B 站观看快速上手与使用演示](https://www.bilibili.com/video/BV1a1bv68EuA/)
* 📺 **YouTube**:[在 YouTube 观看视频演示](https://youtu.be/hAvsj7uiaCw)
### 🎬 视频与讨论
* 📺 **哔哩哔哩 (Bilibili)**:[功能演示](https://www.bilibili.com/video/BV1boHx61Enc/) · [角色配音演示](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
* 💬 **Reddit**:[r/comfyui 上的介绍帖](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
### 👀 看图上手
### 💡 它能帮你解决什么
**1. 扫描一次,以后看封面挑模型。** 点 **扫描**,每个模型都按文件哈希去 C 站认一遍。就算文件叫 `下载 (3).safetensors`,也能显示出它真正的名字、封面、触发词和底模。
**1. 模型太多,认不出谁是谁。** 文件叫 `下载 (3).safetensors`,根本看不出是什么。点一次 **扫描**,每个模型都按文件哈希去 C 站认一遍,显示出它真正的名字、封面、触发词和底模。以后看封面挑模型就行。
<p align="center"><img src="docs/images/scan.webp" width="520" alt="扫描结果:每个文件都在 C 站认出来了"></p>
**2. 打开别人的工作流,不用自己找模型。** 缺模型时画布上方会提示。你电脑里其实有、只是改了名或换了文件夹的,点一下就换上,红框随之消失。
**2. 打开别人的工作流,一片红框。** 模型检查会告诉你缺哪些模型。如果工作流里带着模型指纹(装了本插件的人出图、保存工作流时会自动记下,除非他在设置里关掉),你电脑里其实有、只是改了名或换了文件夹的,点一下就换上;真没有的可以从 C 站或 Hugging Face 下载,下载完按指纹核对过才用。没有指纹的工作流只能按文件名找:别处有同名文件,会列出来让你确认。
<p align="center"><img src="docs/images/model-check.webp" width="760" alt="模型检查一键换上 3 个缺失的模型"></p>
**3. 找到以前的图,拿回当时的工作流。** 在图库里按提示词、模型或 LoRA 搜,再把图拖到画布上。
**3. 这张图当时是怎么出的?** 在图库里按提示词、模型或 LoRA 搜,再把图拖到画布上,当时的工作流就回来了。
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="图库搜索后把图拖到画布"></p>
**4. 存下“模型 + LoRA + 提示词”的搭配,一拖就用。** 把搭配卡片拖到画布空白处,就放出一组连好线的节点,画布上原有的东西不动。
**4. 每次都要重新配一遍模型、LoRA 和提示词。** 在画布上选中这几个节点,右键 **存为搭配**,它们就成了画布旁的一张卡片。下次点 **放到画布**(或者直接拖卡片),点哪里,那组连好线的节点就出现在哪里,画布上原有的东西不动。搭配里的模型和提示词以后还能换。
<p align="center"><img src="docs/images/combo.webp" width="760" alt="搭配卡片拖到画布变成连好线的节点"></p>
<p align="center"><img src="docs/images/combo.webp" width="760" alt="选中的节点存为搭配,再放回画布变成连好线的节点"></p>
**5. 像搭积木一样改提示词。** 选中提示词节点,提示词工坊把它拆成词块:点一下调权重,拖动换位置,输入就加(中文会自动译成英文)。
**5. 提示词太长,改起来麻烦。** 选中提示词节点,提示词工坊把它拆成词块:点一下调权重,拖动换位置,输入就加(中文会自动译成英文,比如输入“蓝宝石”加进去的是 sapphire)。
<p align="center"><img src="docs/images/prompt-studio.webp" width="760" alt="提示词工坊按词块编辑提示词节点"></p>
**6. 插件改过什么都查得到,还能撤销。** 插件做的每一处改动都列在“操作记录”里,带 **撤销** 和 **在画布上找到**。
<p align="center"><img src="docs/images/activity.webp" width="560" alt="操作记录和撤销按钮"></p>
**7. 给角色配音**(需要 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)):导入 GPT-SoVITS 角色,每种情绪一段参考音频,在配音页写台词直接生成。
**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>
窗口左侧的图标栏,每个页面一个图标。
@@ -168,50 +201,53 @@ The first time you open it, start on **Home**: **Scan model folders** fills in c
| **🕘 记录** | 按天列出插件做过的每一处改动。<br>画布上:哪个节点的设置从什么改成了什么、加了或删了哪些节点、打开了哪个工作流。<br>文件上:模型、封面、配方、素材、笔记、图片、音频。<br>**在画布上找到** 可以直接跳到那个节点;画布上的改动在没被再改过之前可以点 **撤销**,其余只记录。你自己在画布上的修改不会记进来。 |
| **📦 模型** | 模型带封面、触发词和底模信息。<br>顶部标签切换类型(Checkpoint、LoRA、VAE…),连同子文件夹一起列出;左侧文件夹列表可以只看某个文件夹。<br>**+** 一键在画布上创建加载节点;也可以改名、写备注、换封面,或只扫描这一个模型。 |
| **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹。<br>可以按提示词、模型、LoRA、seed、文件名或模型哈希搜索。<br>点开图片能看到生成参数;拖到画布上可以还原当时的工作流。<br>觉得好就点 **☆**:存成整个工作流、搭配(模型 + LoRA + 提示词),或者只存提示词。 |
| **🪡 工作流** | 工作流配方:把完整工作流或其中一段,连同模型、封面、备注和参数一起存下来。<br>卡片会显示这台电脑上模型是否齐全;拖到画布上即可载入。<br>可以比较、恢复历史版本。 |
| **🪡 工作流** | 工作流配方:把完整工作流或其中一段,连同模型、封面、备注和参数一起存下来。<br>卡片会显示这台电脑上模型是否齐全;拖到画布上即可载入。<br>可以比较、恢复历史版本。<br>卡片上的导出按钮能打成**配方包**(压缩包)发给别人,对方拖进 ⇅ 就多一张配方卡片。 |
| **🧩 搭配** | 把常用的主模型、几个 LoRA 和一段提示词存成一个搭配。<br>按 **放到画布**(或把卡片拖到画布上),就放出一组连好线的节点;画布上原有的东西不动。 |
| **✍️ 提示词** | 在画布旁边打开提示词工坊。选中一个提示词节点,它的提示词框就以词块的样子出现在工坊里,直接改:点一下调权重,双击改字,拖动换位置,输入就加(中文、日文等会自动译成英文),还能在每个词下面显示它的中文、日文、韩文等语言的意思。<br>点词卡(几张常用的,加上你存的)加进去,或者直接拖到画布的提示词框里(正面绿框、负面红框),拖到空白处就新建一个提示词节点。没选中提示词节点时写的是草稿。 |
| **✍️ 提示词** | 提示词工坊,浏览器里的一个页面;点“停到画布旁”可以移到画布旁边,边看画布边改。选中一个提示词节点,它的提示词框就以词块的样子出现在工坊里,直接改:点一下调权重,双击改字,拖动换位置,输入就加(中文、日文等会自动译成英文),还能在每个词下面显示它的中文、日文、韩文等语言的意思。<br>点词卡(几张常用的,加上你存的)加进去,或者直接拖到画布的提示词框里(正面绿框、负面红框),拖到空白处就新建一个提示词节点。没选中提示词节点时写的是草稿。 |
| **🎙️ 角色语音 / 🎧 音频库** | 装了 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后可用:<br>导入 GPT-SoVITS 角色,给每种情绪选参考音频,修正读音;切到“配音”写台词并直接生成。<br>生成的音频都在音频库里。没装时,页面会说明怎么安装,其他功能不受影响。 |
图标栏上的工具:
* **扫描**:读取模型文件夹,按文件哈希从 C 站获取封面、触发词和底模。不需要额外安装 Python 包。
* **模型检查**(原“模型医生”):打开工作流时自动检查,缺模型就在画布上方提示;本地改过名、换过文件夹的同一个文件(按哈希和文件大小认)一键换上,其余的在检查页里自己挑或去 Civitai 找。
* **模型检查**(原“模型医生”):打开工作流时自动检查,缺模型就在画布上方提示;本地改过名、换过文件夹的同一个文件一键换上,靠的是插件记在工作流和图片里的模型指纹(哈希和文件大小;**设置 → 在工作流里记下模型指纹**,默认打开);没有指纹时,同名文件会列出来让你确认。其余的在检查页里自己挑或去 Civitai 找。
* **当前节点**(原“节点助手”):选中画布上的节点,查看它用的模型。可以看图换模型、插入 LoRA;存过的同类节点参数列在一起,每条写明会改哪几项,一键套用;存下的参数也在这里删除。
* **工具回到各自的页面**:工作流分享码(导入导出)在“工作流”页,模型来源(每个模型去哪下载)在“模型”页和模型检查里,翻译在“当前节点”的提示词框上。
* **工具回到各自的页面**:分享与导入在“工作流”页(⇅:把画布上的工作流变成一段短短的分享码,有汉字码和字母码;也能粘贴分享码,或拖入工作流文件、图片、备份压缩包),模型来源(每个模型去哪下载)在“模型”页和模型检查里,翻译在“当前节点”的提示词框上。
* **设置**(最下面):语言、字号、缩略图、视频封面、显示哪些模型文件夹,以及提交反馈。主页和设置里的 **报告问题** 会打开一个已经填好版本和显卡信息的 GitHub 页面。
**不占你的资源**:
- 插件本身不加载任何模型,不和生图抢显存。
- 浏览时显示的是小缩略图,不加载原图;关闭窗口一分半后,图片也会释放掉。
- Anomalous_TTS 的语音模型在 ComfyUI 生图需要显存时会自动让出,点 **卸载模型** 时全部释放。
**接入 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)。
### 📦 快速安装
</details>
1. 在 ComfyUI 的 `custom_nodes` 目录下打开终端执行:
```bash
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
2. 重启 ComfyUI 即可使用。(*也可以直接在 **ComfyUI Manager** 搜索 `Anomalous Model Browser` 安装*)
3. 想用角色配音的话,用同样的方法再装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)。不装也不影响其他功能。
4. **更新插件**:
- 点窗口右上角的 **!**,可以看到当前版本;
- 点 **检查更新 / 切换版本** 打开版本面板,可以更新到最新版、回退到以前的版本,或回到最新版;
- 换完版本后重启 ComfyUI。插件不会自己联网检查更新。
<details>
<summary><b>⚙️ 打开方式、快捷键和更新</b></summary>
打开方式有三种:按 **Shift + B**(再按一次关闭)、点击画布上的悬浮按钮,或者从 ComfyUI 菜单 **扩展 → Anomalous Model Browser** 打开。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 里,可以修改快捷键、插件语言和打开方式(悬浮按钮、运行按钮旁的按钮,或只用扩展菜单)。
第一次打开时会停在 **主页**:点 **扫描模型文件夹** 补全封面和触发词;点 **界面导览** 会逐一指给你看窗口的各个部分。
**更新插件**:
- 点窗口右上角的 **!**,可以看到当前版本;
- 点 **检查更新 / 切换版本** 打开版本面板,可以更新到最新版、回退到以前的版本,或回到最新版;
- 换完版本后重启 ComfyUI。插件不会自己联网检查更新。
> [!NOTE]
> **关于网络:** 扫描模型要连 Civitai(C 站)查封面和触发词,提示词翻译默认用谷歌翻译,这两样在国内一般要开网络代理才能用。不开代理也行:扫描时选 **离线扫描**,只从模型文件推断底模,没有封面和触发词,以后能连上时再扫一次会自动补上。浏览模型、图库、工作流和搭配都不用联网。
</details>
> [!WARNING]
> **备份自己的数据:** 工作流配方、搭配、存下的提示词和节点参数、参数预设存在 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_notebooks`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters` 文件夹。更新插件前顺手备份一份,更安心。
<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 (测试)
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 is released under the [MIT License](LICENSE). 本项目源代码基于 MIT 许可证开源,可自由使用、修改与分发。
+3
View File
@@ -1,5 +1,6 @@
import os
from .api import setup_routes
from .api.usage_ledger import note_prompt
from server import PromptServer
WEB_DIRECTORY = "./web"
@@ -7,6 +8,8 @@ WEB_DIRECTORY = "./web"
# 注册 API 路由
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 = {}
+29 -4
View File
@@ -1,16 +1,20 @@
from . import (
activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog,
model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_report, scan_summary,
local_guard, translation_routes, version_manager,
model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, 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,
)
from .scanner import *
from .config import *
from .notebooks import *
from .parameters import *
import os
from aiohttp import web
EXTENSION_STATIC_PREFIX = '/extensions/Anomalous_Model_Browser/'
# 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
@@ -27,18 +31,22 @@ async def no_cache_extension_middleware(request, handler):
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/batch_select', model_catalog.api_batch_select)
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)
@@ -57,10 +65,12 @@ def setup_routes(app):
# 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)
@@ -71,6 +81,7 @@ def setup_routes(app):
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)
@@ -109,11 +120,16 @@ def setup_routes(app):
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)
@@ -122,4 +138,13 @@ def setup_routes(app):
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)
model_import.register_routes(app)
model_placement.register_routes(app)
backup.register_routes(app)
+4 -1
View File
@@ -248,7 +248,8 @@ async def api_get_activity(request):
async def api_post_activity(request):
"""Canvas changes from the browser: {changes: [...], total, workflow}."""
"""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:
@@ -260,6 +261,8 @@ async def api_post_activity(request):
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})
+598
View File
@@ -0,0 +1,598 @@
"""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
except ImportError:
from model_identity import USER_INFO_SUFFIX, read_json, scan_info_path
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 _same_bytes(a, b):
try:
return os.path.getsize(a) == os.path.getsize(b) and _sha256_file(a) == _sha256_file(b)
except OSError:
return False
def _store_files(directory):
"""(path, '/'-relative path) of every file kept in a store."""
if not os.path.isdir(directory):
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 cover_owners(base):
"""(Civitai's, the user's) covers of a model: Civitai's are copies of <model>.civitai_bak.*."""
backups = [base + suffix for suffix in CIVITAI_BACKUP_SUFFIXES if os.path.isfile(base + suffix)]
covers = [base + suffix for suffix in ACTIVE_COVER_SUFFIXES if os.path.isfile(base + suffix)]
civitai = [cover for cover in covers if any(_same_bytes(cover, backup) for backup in backups)]
return civitai, [cover for cover in covers if cover not in civitai]
def _model_facts(path):
"""(SHA-256, download page) from the model's sidecars; nothing is computed."""
try:
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 = base + USER_INFO_SUFFIX
user_data = read_json(user_file) if os.path.isfile(user_file) else None
civitai, covers = cover_owners(base)
scan_info = scan_info_path(base) if include_scan else None
backups = [base + suffix for suffix in CIVITAI_BACKUP_SUFFIXES if os.path.isfile(base + suffix)]
civitai_cover = civitai[0] if include_civitai_covers and civitai and backups else None
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"] = scan_info[len(base):]
archive.write(scan_info, f"{folder}scan{entry['info']}")
if civitai_cover:
# One copy: it is both the cover and Civitai's saved image (<model>.civitai_bak.*).
entry["civitai_cover"], entry["civitai_bak"] = civitai_cover[len(base):], backups[0][len(base):]
archive.write(backups[0], f"{folder}civitai{entry['civitai_bak']}")
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, base + suffix)
result["model_infos"] += 1
cover, saved = str(entry.get("civitai_cover") or ""), str(entry.get("civitai_bak") or "")
if cover and saved:
if cover not in ACTIVE_COVER_SUFFIXES or saved not in CIVITAI_BACKUP_SUFFIXES:
raise BackupError(f"unknown cover name: {cover}")
info = archive.getinfo(folder + "civitai" + saved)
civitai, mine = cover_owners(base)
has_saved = any(os.path.isfile(base + suffix) for suffix in CIVITAI_BACKUP_SUFFIXES)
if not has_saved:
_write_member(archive, info, base + saved)
if not civitai and not mine and not entry.get("cover"):
_write_member(archive, info, base + cover)
result["model_civitai_covers"] += 1
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 = 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 = 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
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)
+55 -28
View File
@@ -9,6 +9,11 @@ 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
except ImportError: # loaded outside the package (tests)
from net_proxy import CONFIG_KEY as PROXY_KEY, configured as configured_proxy, normalize as normalize_proxy
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 +25,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(), "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 +62,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 +98,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:
+371
View File
@@ -0,0 +1,371 @@
"""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}
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"]}
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"}
+41 -4
View File
@@ -1,4 +1,9 @@
"""Configured model-folder visibility and scan-scope helpers."""
"""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
@@ -6,12 +11,44 @@ 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'
'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")
@@ -149,7 +186,7 @@ def get_active_folder_types():
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', 'unet', 'controlnet', 'vae']
default_types = DEFAULT_TYPES
try:
if os.path.exists(config_path):
@@ -215,7 +252,7 @@ async def api_get_all_folder_types(request):
else:
# Abstract mode
all_types = list(folder_paths.folder_names_and_paths.keys())
default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae']
default_types = DEFAULT_TYPES
configured_types = set()
try:
if os.path.exists(config_path):
+55 -10
View File
@@ -9,13 +9,22 @@ from aiohttp import web
import folder_paths
from .image_search import filter_images
from .media_routes import CARD_THUMBNAIL_STATIC_EXTENSIONS, _build_card_thumbnail
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": []}
@@ -24,7 +33,8 @@ def _collect_gallery_images(output_dir):
images = []
for root, _, files in os.walk(output_dir):
for filename in files:
if os.path.splitext(filename)[1].lower() not in {'.png', '.jpg', '.jpeg', '.webp', '.gif'}:
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))
@@ -34,7 +44,9 @@ def _collect_gallery_images(output_dir):
images.append({
"filename": filename,
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'),
"type": "output", "mtime": mtime,
"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
@@ -58,33 +70,61 @@ def _invalidate_gallery_snapshot():
_gallery_snapshot.update(root=None, expires=0, images=[])
def _gallery_listing(output_dir, refresh, query):
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):
"""The listing, 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)
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) - Output images, optionally searched by embedded generation info."""
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q)&media=all|image|video&hide=mkv,gif
- Outputs (pictures only unless `media` says otherwise), optionally searched by embedded
generation info, without the `hide` formats."""
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()
images = await asyncio.to_thread(_gallery_listing, folder_paths.get_output_directory(), request.query.get('refresh') == '1', query)
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 - {''})
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})
"page": page, "pages": (total + limit - 1) // limit,
"has_videos": has_videos})
except (TypeError, ValueError):
return web.json_response({"error": "Invalid pagination"}, status=400)
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 (cached like model cards); the original stays at ComfyUI's /view."""
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'))
@@ -94,8 +134,13 @@ async def api_output_thumbnail(request):
if not os.path.isfile(file_path):
return web.json_response({"status": "error", "message": "File not found"}, status=404)
served = file_path
if os.path.splitext(file_path)[1].lower() in CARD_THUMBNAIL_STATIC_EXTENSIONS:
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'})
+144
View File
@@ -0,0 +1,144 @@
"""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 ..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 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 = 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)}
+7 -2
View File
@@ -76,11 +76,12 @@ def _normalise_model_ref(value):
def build_record(path):
"""Searchable text, recorded model hashes and referenced model files for one image."""
"""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)
@@ -92,7 +93,11 @@ def build_record(path):
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):
@@ -111,7 +116,7 @@ def build_record(path):
models |= _normalise_model_ref(model_name)
if chunks.get("parameters"):
texts.append(chunks["parameters"])
return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models}
return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models, "types": types}
def _load_json(raw):
+39 -4
View File
@@ -6,9 +6,16 @@ version switches, saved settings) answers only requests from the computer ComfyU
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)."""
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
@@ -26,11 +33,19 @@ OPEN_POSTS = frozenset({
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.")
@@ -56,6 +71,11 @@ def is_local(request):
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:
@@ -72,11 +92,26 @@ def refusal():
@web.middleware
async def local_only_middleware(request, handler):
if needs_local(request.method, request.path) and not is_local(request):
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 may change things: {"local": bool}."""
return web.json_response({"local": is_local(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)})
+98
View File
@@ -0,0 +1,98 @@
"""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 _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."""
wanted = SAME_FOLDER.get(folder_type, folder_type)
seen, found = set(), []
for entry in _index().get(os.path.basename(str(file_name)).lower(), []):
if wanted and entry["folder"] and SAME_FOLDER.get(entry["folder"], entry["folder"]) != wanted:
continue
if entry["url"] not in seen:
seen.add(entry["url"])
found.append(entry)
return found
+8
View File
@@ -399,6 +399,10 @@ async def api_get_materials_by_node_type(request):
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"],
@@ -406,6 +410,10 @@ async def api_get_materials_by_node_type(request):
"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
+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}
+3 -2
View File
@@ -26,7 +26,7 @@ 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'}
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)
@@ -302,7 +302,8 @@ async def api_serve_image(request):
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo'
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska'
}
content_type = content_types.get(ext)
if content_type is None:
+31 -5
View File
@@ -14,6 +14,7 @@ try:
from ..model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info, unmatched_reason
except ImportError:
from model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info, unmatched_reason
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"}
@@ -85,8 +86,18 @@ def _positive_civitai_id(value):
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 any(os.path.exists(base_path + suffix) for suffix in (".info", ".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": "",
@@ -101,13 +112,17 @@ def _read_metadata(file_path):
"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": "",
"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 = [f"{info_base}.info", f"{info_base}.civitai.info"]
for info_file in info_files:
if os.path.exists(info_file):
try:
@@ -156,6 +171,8 @@ def _read_metadata(file_path):
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"]
@@ -163,12 +180,20 @@ def _read_metadata(file_path):
except Exception:
pass
scan = sidecar_info(base_path)
scan = sidecar_info(info_base)
if scan is not None:
metadata["info_source"] = "local" if is_unmatched(scan) else "civitai"
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 = read_json(base_path + USER_INFO_SUFFIX) 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]
@@ -204,7 +229,8 @@ def _get_metadata_cached(file_path, signature):
def get_metadata(file_path):
normalized_path = os.path.realpath(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))
+2 -94
View File
@@ -1,7 +1,6 @@
"""Model folder discovery, listing, search, and presentation lookup."""
import asyncio
import json
import os
import urllib.parse
@@ -15,7 +14,7 @@ from .folder_types import (
get_active_folder_types, get_active_model_roots,
get_active_physical_basenames, get_folder_view_mode,
)
from .path_utils import resolve_folder_subdir
from .path_utils import model_reference_within, resolve_folder_subdir
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."""
@@ -448,11 +447,8 @@ def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False
real_base_dir = os.path.realpath(base_dir)
roots.append((folder_type, path_idx, real_base_dir))
for original, _, relative_path in requested:
relative = relative_path.replace('/', os.sep)
candidate = os.path.realpath(os.path.join(real_base_dir, relative))
try:
if os.path.commonpath((real_base_dir, candidate)) != real_base_dir:
continue
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):
@@ -601,91 +597,3 @@ async def api_get_all_scan_models(request):
limit = int(request.query.get('limit', 0))
payload = await asyncio.to_thread(_collect_all_scan_models, page, limit)
return web.json_response(payload)
async def api_batch_select(request):
folder_key = request.query.get('folderKey', 'ALL')
action = request.query.get('action', 'all')
def matches_condition(file_path, root, base_name):
if action == 'all':
return True
elif action == 'no_preview':
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
if os.path.exists(os.path.join(root, base_name + ext)):
return False
return True
elif action == 'no_desc':
info_file = file_path + '.info'
civitai_info = os.path.join(root, base_name + '.civitai.info')
if os.path.exists(civitai_info):
try:
with open(civitai_info, 'r', encoding='utf-8') as f:
data = json.load(f)
if data.get('description', '').strip():
return False
except: pass
if os.path.exists(info_file):
try:
with open(info_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if data.get('description', '').strip():
return False
except: pass
return True
return False
results = {}
if folder_key == 'ALL':
target_types = get_active_folder_types()
seen_dirs = set()
for t in target_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 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 f in files:
if is_model_file(f):
file_path = os.path.join(root, f)
base_name = os.path.splitext(f)[0]
if matches_condition(file_path, root, base_name):
rel_subfolder = os.path.relpath(root, base_dir)
if rel_subfolder == '.': rel_subfolder = ''
else: rel_subfolder = rel_subfolder.replace('\\', '/')
fkey = f"{t}|{path_idx}|{rel_subfolder}"
if fkey not in results: results[fkey] = []
results[fkey].append(f)
else:
parts = folder_key.split('|')
if len(parts) >= 3:
t = parts[0]
path_idx = int(parts[1])
subfolder = parts[2]
try: paths = folder_paths.get_folder_paths(t)
except Exception: paths = []
if paths and path_idx < len(paths):
try:
base_dir, target_dir = resolve_folder_subdir(t, path_idx, subfolder)
except ValueError:
return web.json_response({"selected": {}})
if os.path.exists(target_dir):
try: entries = os.listdir(target_dir)
except: entries = []
for f in entries:
if is_model_file(f):
file_path = os.path.join(target_dir, f)
if os.path.isfile(file_path):
base_name = os.path.splitext(f)[0]
if matches_condition(file_path, target_dir, base_name):
if folder_key not in results: results[folder_key] = []
results[folder_key].append(f)
return web.json_response({"selected": results})
+442
View File
@@ -0,0 +1,442 @@
"""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
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)
return web.json_response({"results": results, "roots": types, "settings": read_settings()})
# --- 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)
+22 -9
View File
@@ -1,7 +1,7 @@
"""Shows a model file in this computer's file manager: Explorer or Finder with the file
selected, the folder on Linux. The request names the model the way the browser lists it
(type, folder index, subfolder, file name), never a path, and local_guard lets only this
computer ask (it is a POST)."""
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
@@ -10,7 +10,7 @@ import time
from aiohttp import web
from .utils import require_filename, resolve_folder_subdir, resolve_within
from .utils import model_file_within, resolve_folder_subdir
def _explorer_windows():
@@ -90,7 +90,20 @@ def _reveal(path):
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:
@@ -98,15 +111,15 @@ async def api_open_model_folder(request):
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
try:
filename = require_filename(data.get("filename", ""))
_, folder = resolve_folder_subdir(data.get("type", ""), int(data.get("path_idx", 0)), data.get("subfolder", "/"))
path = resolve_within(folder, filename)
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):
return web.json_response({"status": "error", "message": "Model file not found"}, status=404)
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)
_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)
+40 -5
View File
@@ -1,6 +1,7 @@
"""Model preview lookup and bounded custom-cover operations."""
import asyncio
import filecmp
import os
import shutil
import urllib.parse
@@ -8,9 +9,9 @@ import urllib.parse
from aiohttp import web
import folder_paths
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
from .model_constants import CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
from .trash import move_to_trash
from .utils import require_filename, resolve_folder_subdir, resolve_within
from .utils import model_file_within, require_filename, resolve_folder_subdir, resolve_within
def _cache_token(file_path):
try:
@@ -132,12 +133,46 @@ def _preview_url_for_model(folder_type, path_idx, base_dir, file_path):
)
if not preview_file:
return ""
rel_subfolder = os.path.relpath(root, base_dir)
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(preview_file)
version = _cache_token(os.path.join(root, preview_file))
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 a scan kept (<model>.civitai_bak.*) as a URL, or "", and whether the
cover shown is another picture (the user's): a cover is Civitai's when it is a copy of it."""
base = os.path.splitext(file_path)[0]
saved = next((base + suffix for suffix in CIVITAI_BACKUP_SUFFIXES if os.path.isfile(base + suffix)), "")
shown = next((base + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS if os.path.isfile(base + suffix)), "")
try:
custom = bool(shown) and not (saved and filecmp.cmp(shown, saved, shallow=False))
except OSError:
custom = bool(shown)
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))
+11 -3
View File
@@ -2,6 +2,7 @@
import json
import os
import re
import shutil
from aiohttp import web
@@ -23,7 +24,7 @@ try:
from ..model_identity import USER_INFO_SUFFIX, read_json
except ImportError:
from model_identity import USER_INFO_SUFFIX, read_json
from .utils import require_filename, resolve_folder_subdir, resolve_within
from .utils import model_file_within, require_filename, resolve_folder_subdir
def _first_existing_sidecar(base_path, suffixes):
for suffix in suffixes:
@@ -96,7 +97,7 @@ async def api_delete_model(request):
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
model_path = resolve_within(target_dir, filename)
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):
@@ -134,6 +135,10 @@ async def api_delete_model(request):
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()
@@ -162,7 +167,7 @@ async def api_update_metadata(request):
try:
filename = require_filename(extracted_filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
file_path = resolve_within(target_dir, filename)
file_path = model_file_within(target_dir, filename)
except (ValueError, KeyError):
pass
@@ -182,6 +187,9 @@ async def api_update_metadata(request):
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,
+271
View File
@@ -0,0 +1,271 @@
"""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
except ImportError: # loaded outside the package (tests)
from model_identity import file_sha256
from model_kind import inspect_file
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, unless another model in
its folder has that stem too (then they are shared, and stay)."""
folder, name = os.path.split(path)
stem = os.path.splitext(name)[0]
if any(os.path.isfile(os.path.join(folder, stem + ext)) for ext in MODEL_EXTENSIONS if stem + ext != name):
return []
return [(os.path.join(folder, stem + suffix), suffix) for suffix in SIDECAR_SUFFIXES
if os.path.isfile(os.path.join(folder, stem + suffix))]
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 = 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}")
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)
+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)
+1 -1
View File
@@ -10,7 +10,7 @@ from .model_catalog import (
_allowed_folder_types, _collect_all_scan_models, _collect_folder_models,
_collect_folders, _find_model_sync, _iter_search_models, _model_info_for_path,
_resolve_paths_to_model_info_sync, _resolve_paths_to_previews_sync,
api_base_models, api_batch_select, api_compatible_models, api_find_model,
api_base_models, api_compatible_models, api_find_model,
api_get_all_scan_models, api_get_folders, api_get_models,
api_resolve_paths_to_previews,
)
+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)
+56 -1
View File
@@ -9,7 +9,7 @@ from aiohttp import web
import folder_paths
from .utils import require_filename, resolve_within
from .recipe_constants import MAX_RECIPE_BYTES
from .recipe_images import _parameter_gallery_images
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
@@ -50,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
@@ -152,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
})
@@ -349,6 +387,23 @@ async def api_rename_parameter(request):
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:
+18 -4
View File
@@ -32,16 +32,30 @@ def _model_roots():
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():
candidate = os.path.realpath(os.path.join(base_dir, relative_value))
try:
if os.path.commonpath((base_dir, candidate)) != base_dir:
continue
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):
@@ -68,7 +82,7 @@ def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'):
def require_filename(filename):
if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename:
if not isinstance(filename, str) or filename in ("", ".", "..") or os.path.basename(filename) != filename:
raise ValueError("Invalid filename")
return filename
+3
View File
@@ -7,6 +7,9 @@ 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
+30 -1
View File
@@ -1,6 +1,7 @@
"""Contained Recipe image assets and bounded output-gallery inspection."""
from io import BytesIO
from functools import lru_cache
import hashlib
import heapq
import json
@@ -192,6 +193,34 @@ def _recipe_gallery_images(node_signature):
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):
@@ -200,7 +229,7 @@ def _parameter_gallery_images(parameter_signature):
scanned = 0
for mtime_ns, image_path in _recent_output_pngs(output_dir):
scanned += 1
if _embedded_parameter_signature(image_path) != parameter_signature:
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("\\", "/")
+45 -80
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,6 +12,7 @@ import shutil
import stat
import tempfile
import time
import urllib.parse
import uuid
import zipfile
@@ -19,10 +23,6 @@ from .recipe_store import get_recipes_dir
from .utils import require_filename, resolve_within
# Release gates: package transfers stay closed until validation is complete.
# Keep the format implementation for compatibility testing.
RECIPE_PACKAGE_EXPORT_ENABLED = False
RECIPE_PACKAGE_IMPORT_ENABLED = False
PACKAGE_VERSION = 1
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
MAX_ENTRY_COUNT = 256
@@ -134,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")
@@ -212,7 +224,7 @@ 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
include_model_notes = options.get("include_model_notes", True) is True
@@ -372,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()
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)
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), _existing_recipe_names(recipes_dir))
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"
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_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:
@@ -419,54 +415,33 @@ 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):
if not RECIPE_PACKAGE_EXPORT_ENABLED:
return web.json_response({
"status": "error",
"code": "recipe_export_disabled",
"message": "Recipe package export is temporarily unavailable",
}, status=503)
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
@@ -481,19 +456,13 @@ 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):
if not RECIPE_PACKAGE_IMPORT_ENABLED:
return web.json_response({
"status": "error", "code": "recipe_import_disabled",
"message": "Recipe package import is temporarily unavailable",
}, status=503)
try:
raw = await request.content.read(MAX_UPLOAD_BYTES + 1)
report = await asyncio.to_thread(_inspect_package, raw)
@@ -507,17 +476,15 @@ 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())),
})
async def api_import_recipe_package_commit(request):
if not RECIPE_PACKAGE_IMPORT_ENABLED:
return web.json_response({
"status": "error", "code": "recipe_import_disabled",
"message": "Recipe package import is temporarily unavailable",
}, status=503)
try:
payload = await request.json()
token = payload.get("token")
@@ -529,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})
+54 -3
View File
@@ -29,6 +29,9 @@ ALL_IN_ONE_LOADER_SPECS = {
"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):
@@ -60,6 +63,29 @@ def _model_reference_specs(node):
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:
@@ -153,9 +179,7 @@ def _build_model_references(recipe, verify_identities=False):
for node in workflow.get("nodes", []) if isinstance(workflow, dict) else []:
if not isinstance(node, dict):
continue
values = _widget_values(node)
for widget_index, category, widget_name in _model_reference_specs(node):
saved_value = values[widget_index] if widget_index < len(values) else None
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:
@@ -277,6 +301,31 @@ def _normalise_source_image(value):
}
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")
@@ -324,6 +373,8 @@ def _normalise_recipe(payload):
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"}:
+36 -1
View File
@@ -26,13 +26,14 @@ from .recipe_schema import (
_enrich_recipe as _schema_enrich_recipe, _normalise_recipe, _normalise_source_image,
_identity_for_reference as _schema_identity_for_reference,
_model_reference_key, _model_reference_specs,
_model_roots, _preserve_model_reference_fields,
_model_roots, _normalise_focus_params, _preserve_model_reference_fields,
_resolve_exact_model_reference, _updated_recipe, _workflow_identity_for_reference,
)
from .recipe_store import (
_archive_recipe, _delete_recipe_with_history, _history_dir, _list_recipe_history,
_list_recipes, _read_recipe, _write_recipe, get_recipes_dir,
)
from .local_guard import may_change
from .utils import require_filename, resolve_within
from .workflow_schema import (
_clean_volatile_params, _node_title, _node_type, _normalise_workflow_key,
@@ -359,6 +360,40 @@ async def api_update_recipe(request):
})
def _set_recipe_focus(recipes_dir, filename, entries):
"""Writes a recipe's focus values (重点参数) alone: no version kept, nothing else read again."""
focus = _normalise_focus_params(entries)
path = resolve_within(recipes_dir, filename)
if os.path.getsize(path) > MAX_RECIPE_BYTES:
raise ValueError("Recipe is too large")
recipe = _read_recipe(path)
if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict):
raise ValueError("Invalid recipe")
params = recipe.get("params") if isinstance(recipe.get("params"), dict) else {}
recipe["params"] = {**params, "focusParams": focus}
_write_recipe(recipes_dir, filename, recipe)
return focus
async def api_set_recipe_focus(request):
"""Marks or notes a recipe's focus values (a star in its details). 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:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid filename")
focus = await asyncio.to_thread(_set_recipe_focus, get_recipes_dir(), filename, payload.get("focusParams"))
except FileNotFoundError:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid focus values"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not update recipe"}, status=500)
return web.json_response({"status": "success", "focusParams": focus})
async def api_get_recipe_history(request):
try:
filename = require_filename(request.query.get("filename", ""))
+72
View File
@@ -0,0 +1,72 @@
"""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
except ImportError: # loaded outside the package (tests)
from model_identity import USER_INFO_SUFFIX, read_json
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 = 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})
+4 -1
View File
@@ -72,6 +72,7 @@ class ScanJob:
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):
@@ -87,6 +88,7 @@ class ScanJob:
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):
@@ -116,6 +118,7 @@ class ScanJob:
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],
}
@@ -129,7 +132,7 @@ class ScanJob:
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", "errors")},
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)
+219 -28
View File
@@ -1,34 +1,63 @@
"""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."""
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, sidecar_info, unmatched_reason
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, sidecar_info, unmatched_reason
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 _summarize():
"""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, outside hidden
folders (tools keep their working files there)."""
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()
total = matched = 0
unmatched, new, skipped = [], [], []
for base_dir in get_active_scan_paths():
located = locate_root(base_dir) or ("", 0)
for root, _dirs, files in os.walk(base_dir):
@@ -42,30 +71,192 @@ def _summarize():
if real in seen:
continue
seen.add(real)
if other:
skipped.append({"type": located[0], "path_idx": located[1], "filename": filename,
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")})
continue
total += 1
info = sidecar_info(os.path.splitext(path)[0])
if info is not None and not is_unmatched(info):
matched += 1
continue
item = {"type": located[0], "path_idx": located[1], "filename": filename,
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")}
if info is None:
new.append(item)
else:
item.update(reason=unmatched_reason(info), base=info.get("baseModel") or "")
unmatched.append(item)
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."""
return web.json_response(await asyncio.to_thread(_summarize))
"""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))
+4 -2
View File
@@ -17,8 +17,10 @@ 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__)))
@@ -334,7 +336,7 @@ async def api_scan_folder(request):
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)
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))
@@ -442,7 +444,7 @@ async def api_scan_all(request):
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)
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)
+53 -27
View File
@@ -1,16 +1,24 @@
"""Bounded translation-provider HTTP route."""
"""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", "zh-CN": "ZH", "zh-TW": "ZH-HANT", "en": "EN", "ja": "JA", "ko": "KO",
"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",
}
@@ -20,18 +28,43 @@ def _translate_with_deepl(text, tl, deepl_key):
d_tl = _DEEPL_TARGETS.get(tl)
if not d_tl:
return None
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:
# 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
@@ -42,7 +75,7 @@ def _translate_with_google(text, tl):
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 urllib.request.urlopen(req, timeout=5) as resp:
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]])
@@ -65,7 +98,7 @@ def _translate_with_mymemory(text, tl):
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
with urllib.request.urlopen(req, timeout=5) as resp:
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:
@@ -92,31 +125,24 @@ async def api_translate(request):
if not text:
return web.json_response({"translated": "", "status": "success"})
# 1. Try DeepL if configured
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 Exception:
pass
# 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:
print(f"[Anomalous] DeepL translate failed: {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"})
return web.json_response({"translated": translated, "status": "success", "engine": "google", **why})
except Exception as e:
print(f"[Anomalous] Google translate failed: {e}")
@@ -124,7 +150,7 @@ async def api_translate(request):
try:
translated = await asyncio.to_thread(_translate_with_mymemory, text, tl)
if translated:
return web.json_response({"translated": translated, "status": "success", "engine": "mymemory"})
return web.json_response({"translated": translated, "status": "success", "engine": "mymemory", **why})
except Exception as e:
print(f"[Anomalous] MyMemory translate failed: {e}")
raise e
+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)
+105 -66
View File
@@ -6,6 +6,9 @@ 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
@@ -20,6 +23,11 @@ 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.
@@ -34,6 +42,21 @@ _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."""
@@ -52,14 +75,14 @@ def _git_executable():
return found
def _git(*args, timeout=30, check=True):
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", PLUGIN_DIR, *args],
[_git_executable(), "-C", _root(repo), *args],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout, env=env, **kwargs,
)
@@ -85,17 +108,17 @@ def version_key(tag):
return (numbers, 1 if not suffix else 0, suffix)
def current_state():
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(PLUGIN_DIR, ".git")) and _git_out("rev-parse", "--is-inside-work-tree") != "true":
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")
branch = _git_out("symbolic-ref", "--short", "-q", "HEAD") or None
exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD") or None
described = _git_out("describe", "--tags", "--always")
base_tag = _git_out("describe", "--tags", "--abbrev=0") or None
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).stdout
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,
@@ -106,8 +129,8 @@ def current_state():
"label": exact_tag or described or commit[:12],
"dirty": bool(changed),
"changed_files": changed[:20],
"remote": _git_out("remote", "get-url", "origin") or None,
"previous": _read_state().get("previous"),
"remote": _git_out("remote", "get-url", "origin", repo=repo) or None,
"previous": _read_state(repo).get("previous"),
}
@@ -124,7 +147,7 @@ def _fetch_github_releases(owner, repo):
"Accept": "application/vnd.github+json",
"User-Agent": "Anomalous-Model-Browser",
})
with urllib.request.urlopen(request, timeout=NETWORK_TIMEOUT) as response:
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 []:
@@ -141,36 +164,50 @@ def _fetch_github_releases(owner, repo):
return releases
def _remote_tags():
output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3)
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 list_releases(force=False):
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()
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 _releases_cache["data"] and now < _releases_cache["expires"]:
releases, source = _releases_cache["data"]
if not force and cache["data"] and now < cache["expires"]:
releases, source = cache["data"]
else:
releases, source = None, None
repo = _github_repo(state.get("remote"))
if repo:
github = _github_repo(state.get("remote"))
if github:
try:
releases, source = _fetch_github_releases(*repo), "github"
releases, source = _fetch_github_releases(*github), "github"
except Exception:
releases = None
if releases is None:
releases, source = _remote_tags(), "git"
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)
_releases_cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source))
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"]]
@@ -188,24 +225,25 @@ def list_releases(force=False):
# ---------- switching ----------
def _read_state():
def _read_state(repo=None):
try:
with open(STATE_FILE, "r", encoding="utf-8") as handle:
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):
temporary = STATE_FILE + ".tmp"
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, STATE_FILE)
os.replace(temporary, path)
def _require_switchable():
state = current_state()
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"]:
@@ -213,25 +251,26 @@ def _require_switchable():
return state
def _known_tag(tag):
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()["releases"]}
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):
_git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT)
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):
def preview_switch(tag, repo=None):
"""Fetch the target and report what switching would mean, without changing anything."""
state = _require_switchable()
_known_tag(tag)
_fetch_tag(tag)
has_switcher = _git("cat-file", "-e", f"refs/tags/{tag}:{SWITCHER_MARKER}", check=False).returncode == 0
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 {
@@ -243,15 +282,15 @@ def preview_switch(tag):
}
def switch_to(tag):
def switch_to(tag, repo=None):
with _lock:
state = _require_switchable()
_known_tag(tag)
_fetch_tag(tag)
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}")
_write_state({"previous": previous, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
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():
@@ -282,39 +321,39 @@ def return_to_latest():
return {"state": current_state(), "restart_required": True}
def undo_last_switch():
def undo_last_switch(repo=None):
with _lock:
previous = _read_state().get("previous")
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()
here = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
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"])
_git("checkout", "--quiet", previous["branch"], repo=repo)
else:
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"])
_write_state({"previous": here, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
_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):
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):
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)
return error_response(error)
async def _json_body(request):
async def json_body(request):
try:
data = await request.json()
except Exception:
@@ -327,32 +366,32 @@ async def api_get_version(request):
try:
return web.json_response({"success": True, "state": await asyncio.to_thread(current_state)})
except VersionError as error:
return _error_response(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")
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", "")))
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", "")))
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)
return await run(return_to_latest)
async def api_undo_switch(request):
"""POST /anomalous/version/undo"""
return await _run(undo_last_switch)
return await run(undo_last_switch)
def register_routes(app):
+54 -20
View File
@@ -2,7 +2,13 @@
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).
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
@@ -12,11 +18,29 @@ 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."""
"""Civitai did not answer (network, timeout or server error) after the retries; args[0] says
why ('timeout', 'refused', 'http:503' …)."""
def _load_api_key():
@@ -46,32 +70,42 @@ def _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."""
url = f"https://civitai.com/api/v1/model-versions/by-hash/{file_hash}"
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):
try:
with urllib.request.urlopen(urllib.request.Request(url, headers=_headers()), timeout=15) as response:
return json.loads(response.read().decode('utf-8'))
except urllib.error.HTTPError as e:
if e.code == 404:
print(f"\033[93m[Skip] 模型 Hash {file_hash} 未在 Civitai 找到 (404)。\033[0m")
return None
print(f"[-] 请求异常,状态码: {e.code} (尝试 {attempt+1}/{max_retries})")
except urllib.error.URLError as e:
print(f"[-] 网络请求超时或异常: {e.reason} (尝试 {attempt+1}/{max_retries})")
except Exception as e:
print(f"[-] 未知异常: {e} (尝试 {attempt+1}/{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(file_hash)
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://civitai.com/api/v1/models/{model_id}", headers=_headers())
with urllib.request.urlopen(request, timeout=10) as response:
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}")
@@ -82,7 +116,7 @@ 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 urllib.request.urlopen(urllib.request.Request(url, headers=_headers()), timeout=15) as response:
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"
+1 -1
View File
@@ -21,4 +21,4 @@ open only the topic needed for the current task.
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
`.agents/logs/ai_lessons.md`.
[`lessons.md`](lessons.md).
+242 -11
View File
@@ -9,9 +9,8 @@ recipe persistence, or filesystem-facing routes.
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. `tests/test_route_manifest.py`
locks the HTTP method/path pairs so internal ownership changes cannot silently
drop, duplicate, or rename an endpoint.
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
@@ -20,7 +19,20 @@ 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.
- `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
@@ -47,13 +59,25 @@ look; every change under `/anomalous/` answers only the computer ComfyUI runs on
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`).
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`).
`/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`.
@@ -145,7 +169,15 @@ 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`, `done` with the unchanged count). One model's error is reported and
(`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
@@ -153,12 +185,211 @@ read its result. The scan page's picked or listed models go to `/anomalous/scan_
`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) or
`offline`. Online scans look up `network` and `offline` models again; `not_found` ones
wait for "look up again". After `CIVITAI_DOWN_AFTER` models in a row without an answer
`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). 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
@@ -234,5 +465,5 @@ Keep product wording precise: deletion cleans sidecars; rename migrates them.
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.
- The private local `tests/` directory is ignored and is not imported by runtime
code or shipped in the installable plugin.
- `tests/` holds the plugin's tests (how to run them: `docs/guides/testing.md`,
`tools/run_tests.mjs`). Runtime code never imports it.
+121 -30
View File
@@ -5,11 +5,17 @@ localization, media, or explicit canvas mutations.
## Bootstrap and module ownership
The verified AMB0/AMB1 workflow share-code Import / Export Center is available
from the Workflows page's top bar (⇅) through `window.AMB_WorkflowShare.showUnifiedModal()`. Both directions
are enabled by explicit product decision. It is independent of the paused Recipe
package import/export. Image/workflow hash
injection, host saving, and ordinary image downloads remain unchanged.
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.
@@ -39,7 +45,8 @@ 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 the model folder list; the left
`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
@@ -50,7 +57,7 @@ The Prompt Studio shortcut (its command and setting ids still say "materials") i
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,
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.
@@ -66,9 +73,12 @@ 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`, metadata editing in `ui_model_editor.js`, and advanced selection
in `ui_model_selector.js`.
- Gallery: generated outputs and the full-screen Image Detail Studio Workbench.
`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).
@@ -76,17 +86,49 @@ The main surfaces are:
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`; Put on canvas lays a combo out as a new wired group of nodes (Esc while it
`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).
Current node). 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) 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.
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;
@@ -104,12 +146,25 @@ 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.
Native commands own the default `Shift + B` browser binding (Ctrl + Shift + M before v1.58.2: Chrome
and Edge take it for switching profiles) and the
`Ctrl + Shift + L` Prompt Studio binding. Shortcut customization delegates
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
@@ -213,9 +268,14 @@ groups, and rolls back everything it created on failure. It never calls
until they have a complete remapping path. Complete recipes use
`loadGraphData` intentionally so ComfyUI owns new-workflow canvas creation.
`graph_splice.js` handles deliberate MODEL/CLIP insertion. It analyzes declared
port types rather than slot indexes or display names. Ambiguous downstream
fan-out is rejected until a user can choose a branch. Picker candidates come
`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.
@@ -240,10 +300,14 @@ browsing, editing, or already stored data.
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 opens from the rail's
Prompts entry, the Prompt Studio shortcut and Current node's "Edit in Prompt Studio";
the browser folds away while it is open and comes back when it closes (not when
another studio replaces it). There is no assembly board, block draft or plan loading.
(`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
@@ -266,8 +330,12 @@ language picked in the top bar (one of `GLOSS_LANGUAGES`, none by default, remem
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
skips DeepL for a target it does not take and lets MyMemory detect a source that is
neither English nor Chinese.
(`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
@@ -275,7 +343,10 @@ while the page is visible (scheduled after the previous request), on focus/visib
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 card's role decides its box: a positive card never goes into a
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
@@ -313,12 +384,19 @@ provider-specific validation belongs to the backend translation route.
`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. `tests/css_bundle_order.mjs`
guards the unique ordered list and byte-for-byte reconstructed bundle. Shared `--amb-*`
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` rather than changing unrelated surfaces.
be scoped to `.theme-abyssal-scarlet` or `html.amb-theme-light` rather than changing
unrelated surfaces.
Colours come from the tokens in `00-foundation-models.css`, which both themes define:
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
@@ -327,6 +405,19 @@ transparent)`. CSS injected from JS and inline styles use the same tokens. Liter
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
+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…]`,报出文件和行号。
- 同一个坑第二次出现,就把它做成检查,不再只补一段经验。
+34
View File
@@ -80,6 +80,11 @@ The route family is:
- `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
@@ -123,6 +128,35 @@ the star is filled when any exists, and a kept row opens it or removes it: what
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,
+12
View File
@@ -145,6 +145,12 @@ 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.
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
@@ -152,6 +158,12 @@ 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.
+7 -1
View File
@@ -132,7 +132,13 @@ the same normalization and enrichment boundaries as local recipes.
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`;
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;
+51 -27
View File
@@ -4,24 +4,26 @@ Read this document for recipe schemas, cards/detail behavior, package handling,
result galleries, Parameter Notebooks, prompt roles, and the recipe-powered Node
Assistant.
## Package import/export availability
## Recipe packages
Recipe package import and export are temporarily closed pending validation.
Topbar/card/detail buttons are disabled with a localized explanation. The registered
`POST /anomalous/export_recipe_package` route returns HTTP 503 with code
`recipe_export_disabled` before reading request JSON or recipe files.
`RECIPE_PACKAGE_EXPORT_ENABLED` in `api/recipe_packages.py` is a release gate,
not a user setting. `RECIPE_PACKAGE_IMPORT_ENABLED` likewise gates both
`POST /anomalous/import_recipe_package_inspect` and
`POST /anomalous/import_recipe_package_commit`: each returns HTTP 503 with code
`recipe_import_disabled` before reading uploaded bytes, request JSON, inspection
tokens or writing files. Package format helpers remain for compatibility tests;
local saves are unaffected.
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.
Reopening requires an explicit release decision, package round-trip and failure
validation, then restoring the frontend export action and enabling the backend
gates together. Do not reopen package transfers as a side effect of UX work. Workflow
share-code import/export is a separate, verified feature on the Workflows page's top bar (⇅).
`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
@@ -85,19 +87,26 @@ 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 detail view contains Overview, Parameters, Gallery, and Versions as
applicable. Compact cards may ellipsize bounded values while preserving their
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, active tab, and model composition.
`ui_recipe_overview.js` owns the overview; `ui_recipe_parameters.js` owns prompt roles,
parameter editing, raw nodes, and preset saving; and `ui_recipe_models.js`
`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`, save/edit dialogs in
`ui_recipe_dialogs.js`, and shared cover helpers in `ui_recipe_media.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
@@ -165,8 +174,8 @@ 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. The Parameters tab uses a
two-pane history browser: the left pane selects a notebook and the right pane
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.
@@ -211,6 +220,21 @@ ID and guarded by saved node type. Values are `positive`, `negative`, `both`,
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
@@ -224,7 +248,7 @@ 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 remain beta
surfaces until an explicit stability decision changes that status. Localized
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.
+2 -8
View File
@@ -42,12 +42,6 @@
## 验证
在插件根目录运行:
```powershell
node --experimental-vm-modules tests/update_guide.mjs
```
`update_guide.mjs` 加载真实模块,检查双语配置、版本记忆、重看、翻页、关闭清理、
父窗口关闭、存储失败及扫描图标替换后的标签。它不能代替实际浏览器的布局与焦点验证。
更新指南没有单独的自动测试。改动后在浏览器里实际打开 **!** 指南,检查双语、
翻页、重看、关闭和焦点;全部测试的跑法见 `docs/guides/testing.md`。
只有同时修改工坊生命周期或导入导出行为时,才追加对应测试;纯引导样式不运行无关测试。
+15 -1
View File
@@ -100,12 +100,15 @@ 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; `.agents/logs/ai_lessons.md` holds
`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
@@ -149,3 +152,14 @@ 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.
+4 -2
View File
@@ -198,9 +198,11 @@
点角色卡片上的 **编辑情绪**:
最上面是搜索框:输入一句台词里的几个字或文件名,下面列出找到的音频和它的台词,可以试听(▶),点 **设为主声音** 或 **加为情绪**(新加一行情绪,音频和台词都填好,再写个情绪名)。搜台词需要 Anomalous_TTS 接口版本 14,旧版本只搜文件名。
1. **主声音 `{main}`**:选一条 3~10 秒的参考音频。留空的话,节点会自己挑一条。
2. **情绪**:每行填一个情绪名(例如“开心”),再从角色文件夹里选一条参考音频。输入框可以直接打字搜索文件名。
3. **参考台词**:可以不填,节点会自己去找同名 `.txt` 或 GPT-SoVITS 的标注文件。
2. **情绪**:每行填一个情绪名(例如“开心”),再从角色文件夹里选一条参考音频。点一下音频框会列出全部音频(现在用的那条灰着显示,没选新的就还原);也可以直接打字搜索文件名。
3. **参考台词**:换了音频,台词框会自动填上这条音频的台词(节点从同名 `.txt` / `.lab` 或 GPT-SoVITS 的标注文件里找到的,需要 Anomalous_TTS 接口版本 14)。找不到就留空,节点说话时会再找一遍。也可以自己改。
4. 点 **保存**。设置会写进角色文件夹里的 `anomalous_tts.json`,由节点检查后保存。选了不存在的文件时,会提示原因,什么都不会改。
文件名本身带情绪的音频(例如 `Arona_Talk.开心.wav`)会被自动识别,不用再填一遍。只是扩展名的部分不算情绪,例如 `Arona_Talk.ogg.wav`、`Arona_Talk.ogg (1).ogg`。
+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.
+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.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

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.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+55 -37
View File
@@ -14,6 +14,11 @@ import os
import re
import struct
try:
from .model_kind import MAX_HEADER, inspect_file
except ImportError: # the standalone scanner imports this file on its own
from model_kind import MAX_HEADER, inspect_file
USER_INFO_SUFFIX = ".anomalous.json"
@@ -28,6 +33,17 @@ def read_json(path):
return data if isinstance(data, dict) else None
SCAN_CHOICES = ("skip", "scan")
def scan_choice(base):
"""What the user chose about scanning a model, kept in <model>.anomalous.json ("scan"):
"skip" (a scan of its folder leaves it out; picking it by hand still scans it), "scan"
(asked once, scanned like any other) or "" (not chosen). ``base``: the path without extension."""
choice = (read_json(base + USER_INFO_SUFFIX) or {}).get("scan")
return choice if choice in SCAN_CHOICES else ""
def scan_info_path(base):
"""The sidecar holding a scan result: <model>.info, else another tool's <model>.civitai.info.
A .civitai.info with only edits made in older versions (no Civitai ids) does not count,
@@ -52,8 +68,43 @@ def is_unmatched(info):
# Why an unmatched model has no Civitai record: Civitai does not know the file; Civitai could
# not be reached; the scan was offline. Scans before this was recorded leave it empty.
UNMATCHED_REASONS = ("not_found", "network", "offline")
# not be reached; the scan was offline; the file's header is damaged (safetensors_damage), so
# neither Civitai nor the header can tell what it is. Scans before this was recorded leave it empty.
UNMATCHED_REASONS = ("not_found", "network", "offline", "damaged")
def safetensors_damage(file_path):
"""What is wrong with a .safetensors file's header, or "" when it reads whole: "short" (no
room for the header's length), "header_size" (longer than the file), "header" (not the JSON
of a tensor list) or "truncated" (a tensor ends past the file's end: a download cut short).
Reads only the header. A file that cannot be opened now says nothing ("")."""
try:
size = os.path.getsize(file_path)
with open(file_path, "rb") as handle:
head = handle.read(8)
if len(head) < 8:
return "short"
header_size = struct.unpack("<Q", head)[0]
if not header_size or header_size > MAX_HEADER or 8 + header_size > size:
return "header_size"
header = json.loads(handle.read(header_size).decode("utf-8"))
except OSError:
return ""
except ValueError: # bad UTF-8 or JSON
return "header"
if not isinstance(header, dict):
return "header"
data_size = size - 8 - header_size
for name, tensor in header.items():
if name == "__metadata__":
continue
offsets = tensor.get("data_offsets") if isinstance(tensor, dict) else None
if not (isinstance(offsets, list) and len(offsets) == 2 and all(isinstance(value, int) for value in offsets)
and 0 <= offsets[0] <= offsets[1]):
return "header"
if offsets[1] > data_size:
return "truncated"
return ""
def unmatched_reason(info):
@@ -107,38 +158,5 @@ def file_sha256(path):
def infer_base_model_from_header(file_path: str) -> str:
"""从 safetensors 头文件的张量键名推断底层 Base Model (用于脱机/HuggingFace 兼容)"""
try:
with open(file_path, "rb") as f:
header_size_bytes = f.read(8)
if len(header_size_bytes) < 8: return 'Unknown'
header_size = struct.unpack('<Q', header_size_bytes)[0]
if header_size > 100 * 1024 * 1024: return 'Unknown'
header_json = json.loads(f.read(header_size).decode('utf-8'))
# 1. 尝试从 __metadata__ 提取
metadata = header_json.get('__metadata__', {})
arch = metadata.get('modelspec.architecture', '')
if 'stable-diffusion-xl' in arch.lower(): return 'SDXL'
if 'stable-diffusion-v1' in arch.lower() or 'runwayml/stable-diffusion-v1-5' in arch.lower(): return 'SD 1.5'
if 'flux' in arch.lower(): return 'Flux.1 D'
if 'sd3' in arch.lower(): return 'SD3'
# 2. 暴力张量键名指纹匹配 (Tensor Fingerprinting)
# 把前 500 个键拼接成字符串以提高检索效率,大部分核心键都在前面
keys_str = " ".join(list(header_json.keys())[:500])
# Flux 指纹
if 'double_blocks.0.img_attn' in keys_str or 'img_in.weight' in keys_str: return 'Flux.1 D'
# SD3 指纹
if 'joint_blocks.0.x_block' in keys_str: return 'SD3'
# SDXL 指纹 (包含两套 text encoder)
if 'conditioner.embedders.1.model' in keys_str or 'label_emb.0.0.weight' in keys_str: return 'SDXL'
# SD 1.5 指纹
if 'cond_stage_model.transformer.text_model' in keys_str or 'model.diffusion_model.input_blocks.0.0.weight' in keys_str: return 'SD 1.5'
return 'Unknown'
except Exception as e:
print(f"[-] 离线底模推断失败: {e}")
return 'Unknown'
"""The base model a .safetensors / .gguf header tells (model_kind.py), else 'Unknown'."""
return inspect_file(file_path).get("base") or "Unknown"
+274
View File
@@ -0,0 +1,274 @@
"""What a model file is, from its header alone: which models folder it belongs in (LoRA,
checkpoint, VAE…) and its base model. Reads a .safetensors header (JSON) or a .gguf header
(key/value metadata and tensor names); never loads tensors and never opens formats that can
carry code (.ckpt/.pt/.pth/.bin). Shared by model import (api/model_import.py) and the
scanner's offline base model (model_identity.infer_base_model_from_header). No imports from
ComfyUI, so it is tested on its own.
`classify(fmt, header)` -> {"kind": a ComfyUI folder name or "", "base": base model or "",
"sure": True when the header names both outright, False when it is a reasoned guess}.
"""
import json
import struct
MAX_HEADER = 100 * 1024 * 1024
GGUF_READ = 16 * 1024 * 1024
# Cross-attention width (the text encoder's output) -> base model, for the SD family.
CONTEXT_WIDTH = {768: "SD 1.5", 1024: "SD 2.1", 2048: "SDXL"}
# ComfyUI-GGUF's architecture names.
GGUF_DIFFUSION = {
"flux": "Flux.1 D", "sd1": "SD 1.5", "sdxl": "SDXL", "sd3": "SD3", "aura": "AuraFlow",
"hidream": "HiDream", "cosmos": "Cosmos", "ltxv": "LTXV", "hyvid": "Hunyuan Video",
"wan": "Wan Video", "lumina2": "Lumina", "qwen_image": "Qwen", "chroma": "Chroma",
}
GGUF_TEXT = ("t5", "t5encoder", "umt5", "llama", "qwen2", "qwen2vl", "qwen3", "gemma", "clip", "mistral")
EMBEDDING_KEYS = {"emb_params", "clip_l", "clip_g", "string_to_param", "string_to_token", "name", "step",
"sd_checkpoint", "sd_checkpoint_name"}
LORA_MARKS = ("lora_up.", "lora_down.", "lora_a.", "lora_b.", "lora.up.", "lora.down.", "hada_w1_a",
"lokr_w1", ".lora_linear_layer.", "dora_scale")
CONTROL_MARKS = ("control_model_", "input_hint_block", "controlnet_cond_embedding", "controlnet_blocks",
"controlnet_x_embedder", "zero_convs", "controlnet_mid_block")
DIFFUSION_STARTS = ("double_blocks.", "single_blocks.", "joint_blocks.", "transformer_blocks.",
"single_transformer_blocks.", "input_blocks.", "blocks.0.self_attn", "net.blocks.", "net.x_embedder.")
def parse_safetensors(data):
"""The header dict from a file's first bytes (8-byte length + JSON); None if not one."""
if len(data) < 8:
return None
size = struct.unpack("<Q", data[:8])[0]
if not 2 <= size <= MAX_HEADER or len(data) < 8 + size:
return None
try:
header = json.loads(data[8:8 + size].decode("utf-8"))
except (UnicodeDecodeError, ValueError):
return None
return header if isinstance(header, dict) else None
class _Reader:
def __init__(self, data):
self.data, self.at = data, 0
def take(self, count):
if self.at + count > len(self.data):
raise EOFError
part = self.data[self.at:self.at + count]
self.at += count
return part
def number(self, fmt):
return struct.unpack("<" + fmt, self.take(struct.calcsize(fmt)))[0]
def text(self):
return self.take(self.number("Q")).decode("utf-8", "replace")
_GGUF_SCALARS = {0: "B", 1: "b", 2: "H", 3: "h", 4: "I", 5: "i", 6: "f", 7: "?", 10: "Q", 11: "q", 12: "d"}
def _gguf_value(reader, kind):
if kind in _GGUF_SCALARS:
return reader.number(_GGUF_SCALARS[kind])
if kind == 8:
return reader.text()
if kind == 9: # an array: skipped (tokenizer word lists are long)
item, count = reader.number("I"), reader.number("Q")
for _ in range(count):
_gguf_value(reader, item)
return None
raise ValueError("unknown gguf value type")
def parse_gguf(data):
"""{"meta": general.* values, "keys": tensor names} from a .gguf's first bytes; None if
not one. What did not fit in `data` is left out."""
if data[:4] != b"GGUF":
return None
reader = _Reader(data)
reader.take(4)
meta, keys = {}, []
try:
if reader.number("I") < 2:
return None
tensors, values = reader.number("Q"), reader.number("Q")
for _ in range(values):
key = reader.text()
value = _gguf_value(reader, reader.number("I"))
if key.startswith("general.") and isinstance(value, (str, int, float)):
meta[key] = value
for _ in range(tensors):
keys.append(reader.text())
dims = reader.number("I")
reader.take(8 * dims + 4 + 8)
except (EOFError, ValueError, struct.error):
pass
return {"meta": meta, "keys": keys}
def read_head(path):
"""(fmt, header) of a model file: ("safetensors", dict), ("gguf", dict), or (None, None)
for other formats and unreadable files."""
lower = path.lower()
try:
with open(path, "rb") as source:
if lower.endswith((".safetensors", ".sft")):
start = source.read(8)
if len(start) < 8:
return None, None
size = struct.unpack("<Q", start)[0]
if size > MAX_HEADER:
return None, None
return "safetensors", parse_safetensors(start + source.read(size))
if lower.endswith(".gguf"):
return "gguf", parse_gguf(source.read(GGUF_READ))
except OSError:
pass
return None, None
def _context_width(header):
"""The cross-attention input width a SD-family file shows (768 / 1024 / 2048), else 0."""
for key, value in header.items():
low = key.lower().replace(".", "_")
if "attn2_to_k" not in low or not isinstance(value, dict):
continue
shape = value.get("shape") or []
if len(shape) == 2 and ("lora_down" in low or "lora_a" in low or low.endswith("to_k_weight")):
return shape[1]
return 0
def _family(meta):
"""An SDXL file's family from the name of the model it was trained on (kohya's metadata)."""
trained_on = " ".join(str(meta.get(key, "")) for key in ("ss_sd_model_name", "ss_base_model", "modelspec.title")).lower()
for mark, name in (("pony", "Pony"), ("illustrious", "Illustrious"), ("noob", "NoobAI")):
if mark in trained_on:
return name
return ""
def _base_from_meta(meta):
arch = str(meta.get("modelspec.architecture", "")).lower()
trained = str(meta.get("ss_base_model_version", "")).lower()
pairs = (("flux", "Flux.1 D"), ("chroma", "Chroma"), ("stable-diffusion-xl", "SDXL"), ("sdxl", "SDXL"),
("stable-diffusion-v1", "SD 1.5"), ("sd_v1", "SD 1.5"), ("stable-diffusion-v2", "SD 2.1"),
("sd_v2", "SD 2.1"), ("stable-diffusion-3", "SD3"), ("sd3", "SD3"), ("wan", "Wan Video"),
("qwen", "Qwen"), ("hunyuan", "Hunyuan Video"))
for mark, name in pairs:
if (arch and mark in arch) or (trained and trained.startswith(mark)):
return name
return ""
def _base_from_keys(header, text):
"""(base, sure) from tensor names; ("", False) when they do not tell."""
if "individual_token_refiner" in text:
return "Hunyuan Video", True
# Cosmos-Predict2 (with or without "net."); Anima adds an adapter for its Qwen3 text
# encoder. Before Wan: their blocks' attention names look alike.
if "llm_adapter" in text:
return "Anima", True
if "adaln_modulation_self_attn" in text:
return "Cosmos", True
if "distilled_guidance_layer" in text:
return "Chroma", True
if "img_mod_1" in text or "txt_norm" in text or "img_mlp_net" in text:
return "Qwen", True
if "double_blocks_0_img_attn" in text or "single_blocks_0_linear1" in text:
return "Flux.1 D", True
if "single_transformer_blocks_0_attn" in text or ("single_transformer_blocks" in text and "x_embedder" in text):
return "Flux.1 D", False
if "joint_blocks_0_x_block" in text:
return "SD3", True
if "blocks_0_self_attn_q" in text and "blocks_0_cross_attn_k" in text:
return "Wan Video", True
if any(mark in text for mark in ("conditioner_embedders_1", "label_emb_0_0", "lora_te1_", "lora_te2_",
"lora_unet_input_blocks", "lora_unet_output_blocks")):
return "SDXL", True
width = _context_width(header)
if width in CONTEXT_WIDTH:
return CONTEXT_WIDTH[width], True
if "cond_stage_model_transformer_text_model" in text:
return "SD 1.5", True
if "input_blocks_1_1_transformer_blocks" in text or "lora_unet_down_blocks" in text:
return "SD 1.5", False
return "", False
def _embedding_base(header):
if "clip_g" in header:
return "SDXL"
shape = (header.get("emb_params") or {}).get("shape") or []
return CONTEXT_WIDTH.get(shape[-1], "") if shape else ""
def _kind(keys, raw, text):
"""The models folder from tensor names: (kind, sure)."""
names = set(keys)
if names and names <= EMBEDDING_KEYS and names & {"emb_params", "clip_l", "clip_g", "string_to_param"}:
return "embeddings", True
if any(mark in text for mark in CONTROL_MARKS):
return "controlnet", True
diffusion = any(key.startswith(("model.diffusion_model.", "diffusion_model.") + DIFFUSION_STARTS) for key in keys)
# A whole model first: some merged checkpoints still carry a LoRA's keys.
if diffusion and any(key.startswith(("first_stage_model.", "vae.")) for key in keys):
return "checkpoints", True
if any(mark in raw for mark in LORA_MARKS):
return "loras", True
if diffusion:
return "diffusion_models", True
if any(key.startswith(("image_proj.", "ip_adapter.")) for key in keys):
return "ipadapter", True
if any(mark in text for mark in ("encoder_down_0_block", "decoder_up_0_block", "encoder_down_blocks_0",
"encoder_downsamples", "first_stage_model_encoder")):
return "vae", True
if "vision_model_encoder_layers" in text and "text_model" not in text:
return "clip_vision", True
if "text_model_encoder_layers" in text or "encoder_block_0_layer_0_selfattention" in text \
or ("model_layers_0_self_attn" in text and "embed_tokens" in text):
return "text_encoders", True
if "conv_first_weight" in text or "body_0_rdb1" in text or ("model_0_weight" in text and "model_1_sub" in text):
return "upscale_models", False
return "", False
def classify(fmt, header):
"""What a parsed header says: {"kind", "base", "sure"} (see the module text)."""
if not header:
return {"kind": "", "base": "", "sure": False}
if fmt == "gguf":
meta, keys = header.get("meta") or {}, header.get("keys") or []
arch = str(meta.get("general.architecture", "")).lower()
if arch in GGUF_DIFFUSION:
return {"kind": "diffusion_models", "base": GGUF_DIFFUSION[arch], "sure": True}
if arch.startswith(GGUF_TEXT):
return {"kind": "text_encoders", "base": "", "sure": True}
header = {key: {} for key in keys}
meta = header.get("__metadata__") if isinstance(header.get("__metadata__"), dict) else {}
keys = [key for key in header if key != "__metadata__"]
raw = "\n".join(keys).lower()
text = raw.replace(".", "_")
kind, sure = _kind(keys, raw, text)
if kind in ("vae", "text_encoders", "clip_vision", "upscale_models", "ipadapter"):
base, base_sure = "", True # shared by several base models: the type's own folder
elif kind == "embeddings":
base, base_sure = _embedding_base(header), True
else:
base = _base_from_meta(meta)
base_sure = bool(base)
if not base:
base, base_sure = _base_from_keys(header, text)
if base == "SDXL":
base = _family(meta) or base
return {"kind": kind, "base": base, "sure": bool(kind) and sure and (base_sure or not base)}
def inspect_file(path):
"""classify() of a file on disk, plus its "format" (None when its header cannot be read)."""
fmt, header = read_head(path)
found = classify(fmt, header)
found["format"] = fmt if header else None
return found
+90
View File
@@ -0,0 +1,90 @@
"""The proxy the plugin's own web requests go through, and why a request failed.
Settings -> Network keeps a proxy address in api/config.json (NETWORK_PROXY). When it is set,
every request of the plugin (Civitai, Hugging Face, GitHub, the translators) goes through it,
and the scan process gets it as HTTP(S)_PROXY. When it is empty, urllib finds the proxy itself:
HTTPS_PROXY, else the Windows proxy (not a PAC script, not a browser extension). ComfyUI's own
requests and other node packs are not touched.
`failure()` names why a request failed in a few fixed words the pages explain. No imports from
ComfyUI or the api package, so the scan process (scraper.py) uses it too.
"""
import json
import os
import re
import socket
import ssl
import urllib.error
import urllib.request
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "api", "config.json")
CONFIG_KEY = "NETWORK_PROXY"
_ADDRESS = re.compile(r"^(?:(https?)://)?([A-Za-z0-9.\-]+|\[[0-9A-Fa-f:]+\]):(\d{1,5})/?$", re.IGNORECASE)
def normalize(text) -> str:
"""'' or 'http://host:port'. ValueError('socks') for a SOCKS address (urllib cannot use
one; proxy apps also offer an HTTP port), ValueError('format') for anything else."""
value = str(text or "").strip()
if not value:
return ""
if value.lower().startswith("socks"):
raise ValueError("socks")
match = _ADDRESS.match(value)
if not match or not 0 < int(match.group(3)) < 65536:
raise ValueError("format")
return f"{(match.group(1) or 'http').lower()}://{match.group(2)}:{int(match.group(3))}"
def configured() -> str:
"""The saved proxy address, or '' (none saved, or the file unreadable)."""
try:
with open(CONFIG_PATH, encoding="utf-8") as f:
return normalize(json.load(f).get(CONFIG_KEY, ""))
except (OSError, ValueError, AttributeError):
return ""
def urlopen(request, timeout, context=None):
"""urllib.request.urlopen through the saved proxy, when there is one."""
proxy = configured()
if not proxy:
if context is None:
return urllib.request.urlopen(request, timeout=timeout)
return urllib.request.urlopen(request, timeout=timeout, context=context)
handlers = [urllib.request.ProxyHandler({"http": proxy, "https": proxy})]
if context is not None:
handlers.append(urllib.request.HTTPSHandler(context=context))
return urllib.request.build_opener(*handlers).open(request, timeout=timeout)
def child_env() -> dict:
"""The environment for a process of ours (the scan) that should use the same proxy."""
env = dict(os.environ)
proxy = configured()
if proxy:
env.update(HTTP_PROXY=proxy, HTTPS_PROXY=proxy, http_proxy=proxy, https_proxy=proxy)
return env
def failure(error) -> str:
"""'timeout' | 'refused' | 'dns' | 'ssl' | 'reset' | 'proxy' | 'http:<code>' | 'other'."""
if isinstance(error, urllib.error.HTTPError):
return f"http:{error.code}"
reason = error.reason if isinstance(error, urllib.error.URLError) else error
if isinstance(reason, (socket.timeout, TimeoutError)):
return "timeout"
if isinstance(reason, ConnectionRefusedError):
return "refused"
if isinstance(reason, socket.gaierror):
return "dns"
if isinstance(reason, (ssl.SSLError, ssl.CertificateError)):
return "ssl"
if isinstance(reason, (ConnectionResetError, ConnectionAbortedError)):
return "reset"
if "tunnel connection failed" in str(reason).lower():
return "proxy"
if "timed out" in str(reason).lower():
return "timeout"
return "other"
+27 -5
View File
@@ -19,7 +19,7 @@ import argparse
import shutil
from model_policies import is_physical_rename_protected
from recycle_bin import move_to_trash
from model_identity import USER_INFO_SUFFIX, computed_file_identity, file_sha256, infer_base_model_from_header, is_unmatched, scan_info_path, sidecar_file_hash, sidecar_info as read_local_info, unmatched_reason
from model_identity import USER_INFO_SUFFIX, computed_file_identity, file_sha256, infer_base_model_from_header, is_unmatched, safetensors_damage, scan_choice, scan_info_path, sidecar_file_hash, sidecar_info as read_local_info, unmatched_reason
from civitai_client import CIVITAI_API_KEY, CivitaiUnreachable, download_media, fetch_civitai_info, fetch_model_description
@@ -75,6 +75,11 @@ def write_scan_progress(progress_file, phase, total=0, current=0, filename=""):
pass
def left_out(root, filename, selected):
"""A model the user set to "don't scan" (model_identity.scan_choice), in a scan of its whole folder."""
return not selected and scan_choice(os.path.join(root, os.path.splitext(filename)[0])) == "skip"
def count_scan_files(target_folder, target_files_basenames, unmatched_only=False):
total = 0
selected = set(target_files_basenames)
@@ -82,6 +87,7 @@ def count_scan_files(target_folder, target_files_basenames, unmatched_only=False
total += sum(
1 for filename in files
if filename.endswith(".safetensors") and (not selected or filename in selected)
and not left_out(root, filename, selected)
and (not unmatched_only or is_unmatched(read_local_info(os.path.join(root, os.path.splitext(filename)[0]))))
)
return total
@@ -98,6 +104,9 @@ def sanitize_filename(name: str) -> str:
return name.strip(' .')
CIVITAI_DOWN_AFTER = 2 # models in a row Civitai did not answer for before the rest of the run stays offline
# Covers in a row that would not download before the rest of the run fetches none: the API can
# answer on civitai.red where the pictures (image.civitai.com) cannot be reached, and each try waits.
COVERS_DOWN_AFTER = 2
def write_report(args, **entry):
@@ -136,12 +145,12 @@ def ask_civitai(args, state, file_hash):
return None, "network"
try:
data = fetch_civitai_info(file_hash)
except CivitaiUnreachable:
except CivitaiUnreachable as unreachable:
state["network_failures"] += 1
if state["network_failures"] >= CIVITAI_DOWN_AFTER:
state["civitai_down"] = True
print("\033[93m[!] 连续几个模型都连不上 Civitai,本次其余模型改为从文件推断,之后可以“重新联网查找”。\033[0m")
write_report(args, event="civitai_down")
write_report(args, event="civitai_down", reason=str(unreachable))
return None, "network"
state["network_failures"] = 0
return data, ("" if data else "not_found")
@@ -181,6 +190,12 @@ def scan_one(args, state, rename_log, root, filename, previous):
file_hash = sidecar_file_hash(previous, file_path, (previous.get("files") or [{}])[0])[0]
file_hash = file_hash or calculate_sha256(file_path)
civitai_data, reason = ask_civitai(args, state, file_hash)
# A damaged header changes the file's hash and hides its tensors: say so instead of
# "Civitai does not know it", so the user downloads it again.
damage = "" if civitai_data else safetensors_damage(file_path)
if damage:
print(f"\033[91m[-] 文件头损坏 ({damage}),文件可能不完整或被改坏了,建议重新下载: {filename}\033[0m")
reason = "damaged"
# What was read from disk is not fetched again: only fresh Civitai data needs the model
# page's description and the cover.
@@ -236,10 +251,14 @@ def scan_one(args, state, rename_log, root, filename, previous):
media_url = next((img.get("url") for img in civitai_data.get("images") or [] if img.get("url")), None)
civitai_covers, user_covers = cover_owners(old_base)
# Download when the data is fresh, or when the model has no cover at all.
if media_url and not state["civitai_down"] and (fresh or not (civitai_covers or user_covers)):
if media_url and not state["civitai_down"] and not state["covers_down"] and (fresh or not (civitai_covers or user_covers)):
if not args.dry_run:
print(f"[*] 正在下载预览媒体...")
saved_path = download_media(media_url, old_base + ".civitai_bak")
state["cover_failures"] = 0 if saved_path else state["cover_failures"] + 1
if state["cover_failures"] >= COVERS_DOWN_AFTER:
state["covers_down"] = True
print("\033[93m[!] 连续几张封面都下载不了,本次其余模型不再下载封面(名字和触发词照常获取)。\033[0m")
if saved_path:
print(f"[+] 媒体下载成功 -> {os.path.basename(saved_path)}")
entry["cover"] = "kept" if user_covers else "new"
@@ -408,7 +427,7 @@ def main():
write_scan_progress(args.progress_file, "scanning", total_files)
current_file = 0
last_progress_write = 0.0
state = {"network_failures": 0, "civitai_down": args.civitai_down}
state = {"network_failures": 0, "civitai_down": args.civitai_down, "cover_failures": 0, "covers_down": False}
counts = {"success": 0, "fail": 0, "unchanged": 0}
for root, _, files in os.walk(target_folder):
@@ -417,6 +436,9 @@ def main():
continue
if target_files_basenames and filename not in target_files_basenames:
continue
if left_out(root, filename, target_files_basenames):
print(f"[*] 已设为不扫描,跳过: {filename}")
continue
previous = read_local_info(os.path.join(root, os.path.splitext(filename)[0]))
if args.retry_unmatched and not is_unmatched(previous):
continue
+39
View File
@@ -0,0 +1,39 @@
// node tests/activity_diff.mjs — canvas snapshots and their difference for the activity log.
import assert from 'node:assert/strict';
import { changeNodeId, diffSnapshots, snapshotGraph } from '../web/modules/activity_diff.js';
const graph = (nodes) => ({ _nodes: nodes });
const node = (id, title, widgets) => ({ id, type: title, title, widgets: Object.entries(widgets).map(([name, value]) => ({ name, value })) });
const before = snapshotGraph(graph([
node(3, 'KSampler', { steps: 20, cfg: 7, seed: 1 }),
node(4, 'CheckpointLoaderSimple', { ckpt_name: 'a.safetensors' }),
node(9, 'Note', { text: 'hello' }),
]));
const after = snapshotGraph(graph([
node(3, 'KSampler', { steps: 30, cfg: 7, seed: 1 }),
node(4, 'CheckpointLoaderSimple', { ckpt_name: 'b.safetensors' }),
node(12, 'LoraLoader', { lora_name: 'x.safetensors', strength_model: 1 }),
]));
assert.deepEqual(diffSnapshots(before, after).map(c => [c.kind, c.node, c.widget, c.before, c.after]), [
['changed', 'KSampler #3', 'steps', '20', '30'],
['changed', 'CheckpointLoaderSimple #4', 'ckpt_name', 'a.safetensors', 'b.safetensors'],
['added', 'LoraLoader #12', undefined, undefined, undefined],
['removed', 'Note #9', undefined, undefined, undefined],
]);
assert.deepEqual(diffSnapshots(before, before), []);
// Buttons and nameless widgets hold no value.
const withButton = snapshotGraph(graph([{ id: 1, type: 'X', title: 'X', widgets: [{ name: 'run', type: 'button', value: 1 }, { value: 5 }] }]));
assert.deepEqual(withButton.get('1').widgets, {});
// Object values compare by content; a widget that appears counts as a change.
const a = snapshotGraph(graph([node(1, 'Img', { image: { name: 'a.png' } })]));
const b = snapshotGraph(graph([node(1, 'Img', { image: { name: 'a.png' }, extra: 2 })]));
assert.deepEqual(diffSnapshots(a, b).map(c => [c.widget, c.before, c.after]), [['extra', '', '2']]);
assert.equal(changeNodeId({ node: 'KSampler #12' }), '12');
assert.equal(changeNodeId({ node: 'Title with # sign #7' }), '7');
assert.equal(changeNodeId({ node: 'no id' }), null);
console.log('activity_diff: ok');
+73
View File
@@ -0,0 +1,73 @@
// Run: node tests/audio_engines.mjs (fetch is stubbed; no ComfyUI needed)
import assert from 'node:assert/strict';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const calls = [];
const character = {
name: '阿罗娜/日配', aliases: ['阿罗娜'], language: 'ja', settings: { format: 1, keep: 1 },
counts: { gpt: 1, sovits: 1, audio: 2 }, version: 'v4', needs: ['v4'],
reference: { audio: 'ref/a.wav', text: 'こんにちは', language: 'ja', source: 'auto' },
emotions: { 开心: { audio: 'ref/a.开心.wav', text: '', language: 'ja', source: 'filename' } },
};
globalThis.fetch = async (url) => {
calls.push(url);
const json = (data, status = 200) => ({ ok: status < 400, status, json: async () => data });
if (url.startsWith('/object_info/')) {
const cls = decodeURIComponent(url.slice('/object_info/'.length));
return json(cls === 'AnomalousTTS_CharacterSpeech' ? { [cls]: {} } : {});
}
if (url.startsWith('/anomalous_tts/characters?name=')) {
return json({ format: 2, character: { ...character, audio: ['ref/a.wav', 'ref/a.开心.wav'] } });
}
if (url.startsWith('/anomalous_tts/characters')) return json({ format: 2, characters: [character, { name: 'broken', error: 'x' }] });
return json(null, 404);
};
const engines = await import(pathToFileURL(path.join(root, 'web/modules/audio_engines.js')).href);
// Summary list → voice groups; main first, filename emotions kept.
const first = await engines.loadVoices();
assert.equal(first.installed, true);
assert.equal(first.error, '');
const arona = first.groups.find(g => g.character === '阿罗娜/日配');
assert.deepEqual(arona.slices.map(s => s.emotion), ['main', '开心']);
assert.equal(arona.has_main, true);
assert.equal(arona.model_version, 'v4'); // interface 15; older nodes give ''
assert.deepEqual(arona.needs, ['v4']);
assert.deepEqual(first.groups.find(g => g.character === 'broken').needs, []);
assert.equal(first.groups.find(g => g.character === 'broken').has_main, false);
// Re-rendering (sidebar click, studio + sidebar together) does not hit the server again.
const before = calls.length;
await Promise.all([engines.loadVoices(), engines.loadVoices(), engines.isTtsInstalled()]);
assert.equal(calls.length, before, `unexpected requests: ${calls.slice(before)}`);
assert.equal(await engines.isTtsInstalled(), true);
assert.ok(!calls.some(url => url.includes('F5TTS')), 'no F5-TTS probe');
// Refresh asks the node to rescan once; later loads use its cache again.
engines.invalidateEngineCache({ rescan: true });
await engines.loadVoices();
assert.ok(calls.includes('/anomalous_tts/characters?refresh=1'));
engines.invalidateEngineCache();
await engines.loadVoices();
assert.equal(calls.at(-1), '/anomalous_tts/characters');
// File lists come from the per-character detail.
const detail = await engines.fetchGptSovitsCharacter('阿罗娜/日配');
assert.deepEqual(detail.audio, ['ref/a.wav', 'ref/a.开心.wav']);
assert.equal(calls.at(-1), `/anomalous_tts/characters?name=${encodeURIComponent('阿罗娜/日配')}`);
// Saving keeps fields Anomalous does not know.
const merged = engines.mergeGptSovitsSettings({ format: 1, keep: 1, reference: { audio: 'old.wav', language: 'ja' } }, {
main: { audio: 'ref/a.wav', text: '' },
emotions: [{ name: '开心', audio: 'ref/a.开心.wav', text: 'x' }, { name: 'main', audio: 'y.wav' }],
});
assert.deepEqual(merged, {
format: 1, keep: 1,
reference: { audio: 'ref/a.wav', language: 'ja' },
emotions: { 开心: { audio: 'ref/a.开心.wav', text: 'x' } },
});
console.log('audio_engines: ok');
+37
View File
@@ -0,0 +1,37 @@
// Run: node tests/audio_node_targets.mjs (pure rules, no DOM)
import assert from 'node:assert/strict';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const { planVoiceDrop, targetForNode } =
await import(pathToFileURL(path.join(root, 'web/modules/audio_node_targets.js')).href);
const ttsNode = (values = ['阿罗娜/日配数据集制', '普拉娜']) => ({
type: 'AnomalousTTS_CharacterSpeech',
widgets: [{ name: 'character', value: '', options: { values } }, { name: 'text', value: '' }],
});
const group = { character: '阿罗娜/日配数据集制', node_value: '阿罗娜/日配数据集制', has_main: true };
// A character name goes into `character`, in the node's own spelling.
const plan = planVoiceDrop(ttsNode(), group);
assert.equal(plan.ok, true);
assert.equal(plan.value, '阿罗娜/日配数据集制');
assert.equal(plan.widget.name, 'character');
assert.equal(planVoiceDrop(ttsNode(['阿罗娜\\日配数据集制']), group).value, '阿罗娜\\日配数据集制', 'Windows spelling');
assert.equal(targetForNode(ttsNode()).speechWidget, 'text');
assert.equal(targetForNode({ comfyClass: 'AnomalousTTS_CharacterSpeech' })?.id, 'anomalous-tts');
// Anything not listed is refused, even with a matching widget name; Load Audio and F5-TTS included.
const lookalike = { type: 'SomeOtherTTS', widgets: [{ name: 'character', options: { values: [group.node_value] } }] };
for (const node of [lookalike, { type: 'LoadAudio', widgets: [] }, { type: 'F5TTSAudio', widgets: [] }]) {
assert.equal(targetForNode(node), null, node.type);
assert.equal(planVoiceDrop(node, group).reason, 'unsupported', node.type);
}
assert.equal(planVoiceDrop(null, group).reason, 'unsupported');
// Refusals that explain themselves.
assert.equal(planVoiceDrop(ttsNode(['普拉娜']), group).reason, 'notListed');
assert.equal(planVoiceDrop(ttsNode(['普拉娜']), group).path, group.node_value);
assert.equal(planVoiceDrop(ttsNode(), { ...group, has_main: false }).reason, 'noMainVoice');
assert.equal(planVoiceDrop({ type: 'AnomalousTTS_CharacterSpeech', widgets: [] }, group).reason, 'unsupported');
console.log('audio node target rules OK');
+85
View File
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { buildScriptPackage, buildTtsPrompt, comboValueForPath, composeScript, joinSegments, parseTaggedSpeech, splitScriptLines, splitSegmentAt, ttsOutputPrefix, usableEmotions } from '../web/modules/audio_script.js';
// Sentence splitting keeps decimals and abbreviations without trailing space.
assert.deepEqual(splitScriptLines('老师好!今天3.5点开会。\nOK. Next line?'), ['老师好!', '今天3.5点开会。', 'OK.', 'Next line?']);
assert.deepEqual(splitScriptLines(' \n\n '), []);
// Ellipses never split; stacked marks and closing quotes stay with their sentence;
// punctuation-only pieces are attached to a neighbour instead of becoming a card.
assert.deepEqual(splitScriptLines('嗯……好吧。'), ['嗯……好吧。']);
assert.deepEqual(splitScriptLines('我……我不知道。那就这样吧!'), ['我……我不知道。', '那就这样吧!']);
assert.deepEqual(splitScriptLines('真的吗?!」他说。'), ['真的吗?!」', '他说。']);
assert.deepEqual(splitScriptLines('你好。\n……\n再见。'), ['你好。……', '再见。']);
assert.deepEqual(splitScriptLines('……你好。'), ['……你好。']);
assert.deepEqual(splitScriptLines('Wait... what? OK.'), ['Wait... what?', 'OK.']);
assert.deepEqual(splitScriptLines('(笑)好的。'), ['(笑)好的。']);
assert.deepEqual(splitScriptLines('好的。\n!!', 'line'), ['好的。!!']);
// Manual split at the caret.
assert.deepEqual(splitSegmentAt('早上好!今天开会。', 4), ['早上好!', '今天开会。']);
assert.equal(splitSegmentAt('早上好!', 0), null);
assert.equal(splitSegmentAt('早上好!', 4), null);
// Line mode keeps a whole paragraph line as one segment.
assert.deepEqual(splitScriptLines('老师好!今天开会。\n\n好开心!', 'line'), ['老师好!今天开会。', '好开心!']);
assert.equal(joinSegments('老师好!', '今天开会。'), '老师好!今天开会。');
assert.equal(joinSegments('Hello.', 'World'), 'Hello. World');
assert.equal(joinSegments('', 'x'), 'x');
// Once a line is tagged, untagged lines must be pinned to {main}; tags are single-braced.
assert.equal(composeScript([
{ text: '早上好', voice: null },
{ text: '好开心', voice: { tag: '{happy}' } },
{ text: '继续', voice: null },
]), '{main} 早上好\n{happy} 好开心\n{main} 继续');
assert.equal(composeScript([{ text: 'a', voice: null }, { text: 'b', voice: null }]), 'a\nb');
const slice = emotion => ({ emotion });
const arona = { group: 'gpt_sovits:Arona', node_value: 'Arona', has_main: true, slices: [slice('main'), slice('happy')] };
assert.deepEqual(usableEmotions(arona), ['main', 'happy']);
// A bundle names the character and tags each line with its emotion.
assert.deepEqual(buildScriptPackage([
{ text: '早上好', emotion: 'main' },
{ text: ' 好开心 ', emotion: 'happy' },
{ text: ' ', emotion: 'happy' },
], arona), { sample: 'Arona', speech: '{main} 早上好\n{happy} 好开心', lineCount: 2 });
// All-main scripts stay plain text.
assert.equal(buildScriptPackage([{ text: 'a', emotion: 'main' }, { text: 'b' }], arona).speech, 'a\nb');
assert.equal(buildScriptPackage([{ text: 'a', emotion: 'main' }], null).error, 'scriptDirectorPickCharacter');
assert.equal(buildScriptPackage([{ text: 'a', emotion: 'sad' }], arona).error, 'scriptDirectorVoiceUnusable');
assert.equal(buildScriptPackage([{ text: 'a', emotion: 'main' }], { ...arona, has_main: false }).error, 'scriptDirectorMainMissing');
assert.equal(buildScriptPackage([{ text: 'a', emotion: 'main' }], { ...arona, slices: [slice('happy')] }).error, 'scriptDirectorMainMissing');
assert.equal(buildScriptPackage([{ text: ' ', emotion: 'main' }], arona).error, 'scriptDirectorEmpty');
// Combo values keep the node's own separator; unknown files are not written.
const windowsCombo = { options: { values: ['阿罗娜\\日配', '普拉娜'] } };
assert.equal(comboValueForPath(windowsCombo, '阿罗娜/日配'), '阿罗娜\\日配');
assert.equal(comboValueForPath(windowsCombo, 'Mika'), null);
assert.equal(comboValueForPath({}, 'audio/x.wav'), 'audio/x.wav');
assert.equal(comboValueForPath(windowsCombo, ''), null);
// Saved speech reads back into segments at its tags.
assert.deepEqual(parseTaggedSpeech('开场\n{happy} 哇!\n{main} 好'), [
{ emotion: 'main', text: '开场' }, { emotion: 'happy', text: '哇!' }, { emotion: 'main', text: '好' },
]);
assert.deepEqual(parseTaggedSpeech(composeScript([{ text: 'a', voice: { tag: '{sad}' } }, { text: 'b', voice: null }])),
[{ emotion: 'sad', text: 'a' }, { emotion: 'main', text: 'b' }]);
assert.deepEqual(parseTaggedSpeech(''), []);
// GPT-SoVITS direct runs: a retaken line gets [take:N] after its emotion tag, only when asked for.
const ttsGroup = { node_value: '阿罗娜/日配', slices: [{ emotion: 'main' }, { emotion: '开心' }] };
const retaken = [{ text: '一。', emotion: 'main', take: 1 }, { text: '二。', emotion: '开心', take: 3 }, { text: '三。', emotion: 'main', take: 2 }];
assert.equal(buildScriptPackage(retaken, ttsGroup, { takes: true }).speech, '{main} 一。\n{开心} [take:3]二。\n{main} [take:2]三。');
assert.equal(buildScriptPackage(retaken, ttsGroup).speech, '{main} 一。\n{开心} 二。\n{main} 三。');
assert.equal(composeScript([{ text: 'a', voice: null, take: 2 }]), '[take:2]a');
// The output folder follows the character, with characters Windows refuses replaced.
assert.equal(ttsOutputPrefix('阿罗娜/日配'), 'audio/阿罗娜/日配/日配');
assert.equal(ttsOutputPrefix('a:b?/..'), 'audio/a_b_/_/_');
const prompt = buildTtsPrompt({ character: '阿罗娜/日配', speech: '{main} 一。', seed: 5, language: 'zh', speed: 1.1 });
assert.deepEqual(prompt[1], { class_type: 'AnomalousTTS_CharacterSpeech', inputs: { character: '阿罗娜/日配', text: '{main} 一。', seed: 5, language: 'zh', speed: 1.1 } });
assert.deepEqual(prompt[2].inputs, { audio: ['1', 0], filename_prefix: 'audio/阿罗娜/日配/日配' });
assert.equal(buildTtsPrompt({ character: 'x', speech: 'a', seed: 1, top_k: 10, temperature: 0.8 })[1].inputs.top_k, 10);
assert.deepEqual(buildTtsPrompt({ character: 'x', speech: 'a', seed: 1, preview: true })[2], { class_type: 'PreviewAudio', inputs: { audio: ['1', 0] } });
assert.equal('preview' in buildTtsPrompt({ character: 'x', speech: 'a', seed: 1, preview: true })[1].inputs, false);
console.log('audio script rules OK');
+106
View File
@@ -0,0 +1,106 @@
// node --experimental-vm-modules tests/canvas_undo.mjs — undo from the activity log.
import assert from 'node:assert/strict';
import { snapshotGraph } from '../web/modules/activity_diff.js';
import { canUndo, keepUndo, undoEntry } from '../web/modules/canvas_undo.js';
globalThis.LiteGraph = { registered_node_types: {} };
function makeNode(id, type, widgets, inputs = []) {
return {
id, type, title: type, inputs, outputs: [],
widgets: Object.entries(widgets).map(([name, value]) => ({ name, value })),
};
}
function makeGraph(nodes) {
const graph = {
_nodes: nodes,
getNodeById: id => graph._nodes.find(node => String(node.id) === String(id)) || null,
remove: node => { graph._nodes = graph._nodes.filter(item => item !== node); },
steps: 0,
};
return graph;
}
function makeApp(graph, key = 'wf-1') {
const tracker = { captureCanvasState() { graph.steps += 1; } };
return { graph, canvas: { graph }, extensionManager: { workflow: { activeWorkflow: { key, changeTracker: tracker } } } };
}
// A value change and an added unconnected node: undone together, one Ctrl+Z step.
{
const sampler = makeNode(3, 'KSampler', { seed: 5, steps: 20, sampler_name: 'euler' });
const graph = makeGraph([sampler]);
const app = makeApp(graph);
const before = snapshotGraph(graph);
sampler.widgets[0].value = 6; // a seed changed meanwhile stays
sampler.widgets[1].value = 30;
sampler.widgets[2].value = 'dpmpp_2m';
graph._nodes.push(makeNode(7, 'CLIPTextEncode', { text: 'a cat' }));
keepUndo('e1', 'wf-1', before, snapshotGraph(graph));
assert.equal(canUndo(app, { id: 'e1' }), true);
undoEntry(app, { id: 'e1' });
assert.equal(sampler.widgets[0].value, 6);
assert.equal(sampler.widgets[1].value, 20);
assert.equal(typeof sampler.widgets[1].value, 'number');
assert.equal(sampler.widgets[2].value, 'euler');
assert.equal(graph.getNodeById(7), null);
assert.equal(graph.steps, 1);
assert.equal(canUndo(app, { id: 'e1' }), false, 'an entry is undone once');
}
// Changed again since, another workflow open, or the added node wired up: no undo.
{
const sampler = makeNode(3, 'Steps', { steps: 20 });
const graph = makeGraph([sampler]);
const before = snapshotGraph(graph);
sampler.widgets[0].value = 30;
keepUndo('e2', 'wf-1', before, snapshotGraph(graph));
assert.equal(canUndo(makeApp(graph, 'wf-2'), { id: 'e2' }), false);
sampler.widgets[0].value = 31;
assert.equal(canUndo(makeApp(graph), { id: 'e2' }), false);
assert.throws(() => undoEntry(makeApp(graph), { id: 'e2' }), /activityUndoGone/);
const graph2 = makeGraph([]);
const before2 = snapshotGraph(graph2);
const added = makeNode(5, 'LoraLoader', { lora_name: 'x' });
graph2._nodes.push(added);
keepUndo('e3', 'wf-1', before2, snapshotGraph(graph2));
added.outputs = [{ links: [11] }];
assert.equal(canUndo(makeApp(graph2), { id: 'e3' }), false);
}
// Removed nodes or rewired inputs are never kept; nor is an entry without an id.
{
const a = makeNode(1, 'A', { v: 1 });
const b = makeNode(2, 'B', { v: 1 }, [{ link: null }]);
const graph = makeGraph([a, b]);
const before = snapshotGraph(graph);
graph._nodes = [b];
keepUndo('e4', 'wf-1', before, snapshotGraph(graph));
assert.equal(canUndo(makeApp(graph), { id: 'e4' }), false);
const graph2 = makeGraph([makeNode(1, 'A', { v: 1 }), b]);
const before2 = snapshotGraph(graph2);
b.inputs[0].link = 4;
b.widgets[0].value = 2;
keepUndo('e5', 'wf-1', before2, snapshotGraph(graph2));
assert.equal(canUndo(makeApp(graph2), { id: 'e5' }), false);
assert.equal(canUndo(makeApp(graph2), {}), false);
}
// A choice this computer no longer has, or only a seed changed: nothing to offer.
{
const loader = makeNode(4, 'CheckpointLoaderSimple', { ckpt_name: 'a.safetensors' });
loader.widgets[0].options = { values: ['b.safetensors'] };
const graph = makeGraph([loader]);
const before = snapshotGraph(graph);
loader.widgets[0].value = 'b.safetensors';
keepUndo('e6', 'wf-1', before, snapshotGraph(graph));
assert.equal(canUndo(makeApp(graph), { id: 'e6' }), false);
const seeded = makeNode(8, 'Noise', { noise_seed: 1 });
const graph2 = makeGraph([seeded]);
const before2 = snapshotGraph(graph2);
seeded.widgets[0].value = 2;
keepUndo('e7', 'wf-1', before2, snapshotGraph(graph2));
assert.equal(canUndo(makeApp(graph2), { id: 'e7' }), false);
}
console.log('canvas_undo: ok');
+80
View File
@@ -0,0 +1,80 @@
// The rules of combo structures that need no canvas (web/modules/combo_slots.js).
import assert from 'node:assert/strict';
import { chainOrder, findPort, fedByLink, fixedNodes, folderFor, isModelWidget, missingTypes, packOf, structureKey, structureSummary } from '../web/modules/combo_slots.js';
const combo = (value, values) => ({ type: 'combo', value, options: { values } });
// Model drop-downs: by the files they list, not by their name.
assert.equal(isModelWidget(combo('ae.safetensors', ['ae.safetensors', 'other.sft'])), true);
assert.equal(isModelWidget(combo('None', ['None', 'Flux\\a.safetensors'])), true); // a LoRA stack row left empty
assert.equal(isModelWidget(combo('flux', ['sdxl', 'sd3', 'flux'])), false); // CLIP type: not a model
assert.equal(isModelWidget(combo('x.gguf', () => ['x.gguf'])), true); // options given as a function
assert.equal(isModelWidget({ type: 'text', value: 'a.safetensors' }), false);
// A box turned into an input and fed by a link is no slot.
const node = { inputs: [{ name: 'lora_name', widget: { name: 'lora_name' }, link: 7 }, { name: 'model', link: 3 }] };
assert.equal(fedByLink(node, { name: 'lora_name' }), true);
assert.equal(fedByLink(node, { name: 'strength' }), false);
// The models folder: the one whose files hold every option ("None" aside).
const lists = new Map([
['loras', new Set(['Flux/a.safetensors', 'b.safetensors'])],
['clip', new Set(['t5.safetensors', 'clip_l.safetensors'])],
['text_encoders', new Set(['t5.safetensors', 'clip_l.safetensors'])],
['checkpoints', new Set(['sdxl.safetensors'])],
['my_loras', new Set(['Flux/a.safetensors', 'b.safetensors', 'c.safetensors'])],
]);
assert.equal(folderFor(['None', 'Flux\\a.safetensors', 'b.safetensors'], 'None', lists), 'loras'); // known folder before a custom one
assert.equal(folderFor(['t5.safetensors', 'clip_l.safetensors'], 't5.safetensors', lists), 'text_encoders'); // clip is the same folder
assert.equal(folderFor(['c.safetensors'], 'c.safetensors', lists), 'my_loras');
assert.equal(folderFor(['nowhere.safetensors'], 'nowhere.safetensors', lists), '');
assert.equal(folderFor([], 'sdxl.safetensors', lists), 'checkpoints'); // no options: the value decides
assert.equal(folderFor([], 'None', lists), '');
lists.set('vae', new Set(['ae.safetensors']));
assert.equal(folderFor(['ae.safetensors', 'pixel_space', 'taesd'], 'ae.safetensors', lists), 'vae'); // built-in choices are no files
// Link ends: same name and type, else the old place, else the one port of that type.
const outputs = [{ name: 'MODEL', type: 'MODEL' }, { name: 'CLIP', type: 'CLIP' }, { name: 'VAE', type: 'VAE' }];
assert.equal(findPort(outputs, 'CLIP', 1, 'CLIP'), 1);
assert.equal(findPort([{ name: 'VAE', type: 'VAE' }, { name: 'CLIP', type: 'CLIP' }], 'CLIP', 0, 'CLIP'), 1); // moved
assert.equal(findPort([{ name: 'clip_out', type: 'CLIP' }], 'CLIP', 1, 'CLIP'), 0); // renamed, one of its type
assert.equal(findPort([{ name: 'a', type: 'CLIP' }, { name: 'b', type: 'CLIP' }], 'CLIP', 5, 'CLIP'), -1); // two: no guess
assert.equal(findPort(outputs, 'MODEL', 0, 'LATENT'), -1); // wrong type
// Node packs and missing node types (LiteGraph's registry).
globalThis.LiteGraph = { registered_node_types: {
UnetLoaderGGUF: { nodeData: { python_module: 'custom_nodes.ComfyUI-GGUF' } },
CLIPTextEncode: { nodeData: { python_module: 'nodes' } },
} };
assert.equal(packOf('UnetLoaderGGUF'), 'ComfyUI-GGUF');
assert.equal(packOf('CLIPTextEncode'), '');
const structure = { nodes: [{ type: 'UnetLoaderGGUF' }, { type: 'Missing', pack: 'Some-Pack' }, { type: 'Missing', pack: 'Some-Pack' }], links: [], slots: [] };
assert.deepEqual(missingTypes(structure), [{ type: 'Missing', pack: 'Some-Pack' }]);
// Same structure, whatever the values.
const one = { nodes: [{ type: 'A', key: 'n1' }], links: [{ from: 'n1', fromName: 'MODEL', to: 'n2', toName: 'model' }], slots: [{ node: 'n1', widget: 'x' }] };
assert.equal(structureKey(one), structureKey(JSON.parse(JSON.stringify(one))));
assert.notEqual(structureKey(one), structureKey({ ...one, slots: [] }));
// Repeated nodes are counted in the summary.
assert.equal(structureSummary({ nodes: [{ name: 'UNet' }, { name: 'Text' }, { name: 'LoRA' }, { name: 'Text' }, { title: 'Mine', name: 'LoRA' }] }),
'UNet + Text ×2 + LoRA + Mine');
// Flow order: loaders first, a LoRA chain from the model outwards, ties as given, loops last.
assert.deepEqual(chainOrder(['p1', 'p2', 'l1', 'l2', 'l3', 'l4', 'unet', 'clip'],
[['l3', 'l1'], ['l1', 'p1'], ['l1', 'p2'], ['unet', 'l2'], ['clip', 'l2'], ['l4', 'l3'], ['l2', 'l4']]),
['unet', 'clip', 'l2', 'l4', 'l3', 'l1', 'p1', 'p2']);
assert.deepEqual(chainOrder(['a', 'b', 'c'], [['a', 'b'], ['b', 'a'], ['x', 'c']]), ['c', 'a', 'b']);
// Nodes without slots are put back as saved, listed in flow order: an upscale model's loader with a slot,
// the node using it and a saver without.
const upscale = {
nodes: [{ key: 'n1', type: 'SaveImage' }, { key: 'n2', type: 'ImageUpscaleWithModel' }, { key: 'n3', type: 'UpscaleModelLoader' }],
links: [{ from: 'n3', to: 'n2' }, { from: 'n2', to: 'n1' }],
slots: [{ node: 'n3', widget: 'model_name' }],
};
assert.deepEqual(fixedNodes(upscale).map(item => item.type), ['ImageUpscaleWithModel', 'SaveImage']);
assert.deepEqual(fixedNodes({ ...upscale, slots: [] }).map(item => item.key), ['n3', 'n2', 'n1']);
assert.deepEqual(fixedNodes({}), []);
console.log('combo_slots: ok');
+25
View File
@@ -0,0 +1,25 @@
// node tests/detached_graph.mjs — a workflow read off the canvas never takes the canvas's graph id.
import assert from 'node:assert/strict';
import { detachedGraph } from '../web/modules/detached_graph.js';
const configured = [];
globalThis.LiteGraph = { LGraph: class { configure(data) { configured.push(data); } } };
const workflow = { id: '3675a66f-fd88-4261-ab21-5b285bd54a02', nodes: [{ id: 3, widgets_values: [20] }] };
detachedGraph(workflow);
detachedGraph(workflow);
const [first, second] = configured;
assert.match(first.id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
assert.notEqual(first.id, workflow.id);
assert.notEqual(first.id, second.id);
assert.deepEqual(first.nodes, workflow.nodes);
assert.equal(workflow.id, '3675a66f-fd88-4261-ab21-5b285bd54a02'); // the caller's workflow is left as it was
// Without crypto.randomUUID (a page over plain http on another computer) the id is made by hand.
const realCrypto = globalThis.crypto;
Object.defineProperty(globalThis, 'crypto', { value: { getRandomValues: array => realCrypto.getRandomValues(array) }, configurable: true });
detachedGraph(workflow);
assert.match(configured[2].id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
Object.defineProperty(globalThis, 'crypto', { value: realCrypto, configurable: true });
console.log('detached_graph ok');
+45
View File
@@ -0,0 +1,45 @@
// Where a downloaded model goes (web/modules/download_places.js).
import assert from 'node:assert/strict';
import { DEFAULT_FOLDER, FOLDER_PRESETS, baseFolder, destinationFor, expandFolder, unsafeFormat } from '../web/modules/download_places.js';
const entry = (value) => ({ value });
const workflow = { place: 'workflow', folder: 'Downloads' };
const fixed = { place: 'folder', folder: 'Downloads' };
// Where the workflow says: its own subfolder (Windows separators too), so the node needs no change.
assert.equal(destinationFor(entry('Flux\\style\\ink.safetensors'), {}, workflow), 'Flux/style/ink.safetensors');
// A bare name goes into the settings' folder; an empty folder means the type's folder itself.
assert.equal(destinationFor(entry('ink.safetensors'), {}, workflow), 'Downloads/ink.safetensors');
assert.equal(destinationFor(entry('ink.safetensors'), {}, { place: 'workflow', folder: '' }), 'ink.safetensors');
// A set folder wins over the workflow's; the file keeps the workflow's name.
assert.equal(destinationFor(entry('Flux/ink.safetensors'), {}, fixed), 'Downloads/ink.safetensors');
// {base}: the usual folder name of the base model, or the folder you already have for it.
assert.equal(baseFolder('SDXL 1.0'), 'SDXL');
assert.equal(baseFolder('Flux.1 D'), 'Flux');
assert.equal(baseFolder('SD 1.5'), 'SD1.5');
assert.equal(baseFolder('Illustrious', ['illustrious', 'pony']), 'illustrious');
assert.equal(baseFolder('SD 1.5', ['sd15']), 'sd15');
assert.equal(baseFolder('Some: New/Model'), 'Some New Model');
assert.equal(baseFolder(''), '');
const subfolders = ['Characters', 'Characters/SDXL', 'Downloads', 'Downloads/flux-models', 'NoobAI'];
assert.equal(expandFolder('{base}', 'Flux.1 D', subfolders), 'Flux'); // the nested one is not beside it
assert.equal(expandFolder('Downloads/{base}', 'Flux.1 D', subfolders), 'Downloads/Flux');
assert.equal(expandFolder('downloads/{base}', 'SDXL 1.0', ['Downloads', 'Downloads/sd-xl']), 'downloads/sd-xl');
assert.equal(expandFolder('{base}', 'NoobAI', subfolders), 'NoobAI');
assert.equal(expandFolder('{base}/loras', '', subfolders), 'loras'); // unknown base: the part goes
assert.equal(destinationFor(entry('a.safetensors'), { base_model: 'Pony' }, { place: 'folder', folder: '{base}' }), 'Pony/a.safetensors');
// Without settings: by base model, the first of the settings' ready-made folders.
assert.equal(DEFAULT_FOLDER, FOLDER_PRESETS[0]);
assert.equal(destinationFor(entry('a.safetensors'), { base_model: 'Illustrious' }, { place: 'workflow' }), 'Illustrious/a.safetensors');
assert.equal(destinationFor(entry('a.safetensors'), {}, { place: 'workflow' }), 'a.safetensors');
assert.equal(baseFolder('SD 2.1'), 'SD2');
assert.ok(FOLDER_PRESETS.every(folder => !folder.includes('*'))); // '*' marks "typed by hand" in the settings
assert.equal(unsafeFormat('x.ckpt'), true);
assert.equal(unsafeFormat('x.PT'), true);
assert.equal(unsafeFormat('x.safetensors'), false);
assert.equal(unsafeFormat('x.gguf'), false);
console.log('download_places: ok');
+193
View File
@@ -0,0 +1,193 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
normalizeFloatingTriggerSize,
normalizeFloatingTriggerStyle,
normalizeEntryMode,
isValidSavedTriggerPosition,
normalizeSavedTriggerPosition,
clampFloatingTriggerPosition,
loadSavedTriggerPosition,
saveTriggerPosition,
clearSavedTriggerPosition
} from '../web/modules/entry_controls.js';
test('normalizeFloatingTriggerSize returns default for invalid values', () => {
assert.equal(normalizeFloatingTriggerSize('small'), 'small');
assert.equal(normalizeFloatingTriggerSize('medium'), 'medium');
assert.equal(normalizeFloatingTriggerSize('large'), 'large');
assert.equal(normalizeFloatingTriggerSize('invalid'), 'medium');
assert.equal(normalizeFloatingTriggerSize(null), 'medium');
assert.equal(normalizeFloatingTriggerSize(undefined), 'medium');
});
test('normalizeFloatingTriggerStyle returns default for invalid values', () => {
assert.equal(normalizeFloatingTriggerStyle('icon'), 'icon');
assert.equal(normalizeFloatingTriggerStyle('pill'), 'pill');
assert.equal(normalizeFloatingTriggerStyle('unknown'), 'icon');
assert.equal(normalizeFloatingTriggerStyle(null), 'icon');
});
test('normalizeEntryMode returns default for invalid values', () => {
assert.equal(normalizeEntryMode('floating'), 'floating');
assert.equal(normalizeEntryMode('topbar'), 'topbar');
assert.equal(normalizeEntryMode('menu'), 'menu');
assert.equal(normalizeEntryMode('other'), 'floating');
});
test('isValidSavedTriggerPosition validates finite numeric coordinates and rejects invalid/null values', () => {
// Nullish or empty
assert.equal(isValidSavedTriggerPosition(null, null), false);
assert.equal(isValidSavedTriggerPosition('100px', null), false);
assert.equal(isValidSavedTriggerPosition(null, '100px'), false);
assert.equal(isValidSavedTriggerPosition('', ''), false);
assert.equal(isValidSavedTriggerPosition('NaN', 'NaN'), false);
// Negative or non-finite coordinates are rejected
assert.equal(isValidSavedTriggerPosition('-10px', '50px'), false);
assert.equal(isValidSavedTriggerPosition('50px', '-10px'), false);
assert.equal(isValidSavedTriggerPosition('Infinity', '100'), false);
// Coordinates in dock area (<70px) are rejected as invalid legacy/dirty positions
assert.equal(isValidSavedTriggerPosition('0px', '0px'), false);
assert.equal(isValidSavedTriggerPosition('50px', '100px'), false);
assert.equal(isValidSavedTriggerPosition('69px', '80px'), false);
// Valid non-negative finite canvas positions outside dock
assert.equal(isValidSavedTriggerPosition('70px', '80px'), true);
assert.equal(isValidSavedTriggerPosition('100px', '200px'), true);
assert.equal(isValidSavedTriggerPosition('500', '300'), true);
assert.equal(isValidSavedTriggerPosition('1800px', '900px'), true);
});
test('normalizeSavedTriggerPosition preserves precise user drag coordinates without distortion', () => {
const pos1 = normalizeSavedTriggerPosition('75px', '48px');
assert.deepEqual(pos1, { x: 75, y: 48 });
const pos2 = normalizeSavedTriggerPosition('120px', '80px');
assert.deepEqual(pos2, { x: 120, y: 80 });
const pos3 = normalizeSavedTriggerPosition('500px', '400px');
assert.deepEqual(pos3, { x: 500, y: 400 });
// Invalid / null coordinates return null
assert.equal(normalizeSavedTriggerPosition(null, null), null);
assert.equal(normalizeSavedTriggerPosition('invalid', 'invalid'), null);
assert.equal(normalizeSavedTriggerPosition('30px', '100px'), null);
});
test('clampFloatingTriggerPosition calculates safe top-left fallback positions when coordinates are absent', () => {
const pos = clampFloatingTriggerPosition({
x: null,
y: null,
width: 60,
height: 60,
viewportWidth: 1920,
viewportHeight: 1080,
margin: 0
});
// Falls back cleanly to top-left safe zone (80, 80)
assert.equal(pos.x, 80);
assert.equal(pos.y, 80);
});
test('clampFloatingTriggerPosition provides safe fallback viewport when viewportWidth/Height is unmeasured', () => {
const pos = clampFloatingTriggerPosition({
x: null,
y: null,
width: 60,
height: 60,
viewportWidth: 0,
viewportHeight: 0,
margin: 0
});
// Uses safe fallback top-left position (80, 80)
assert.equal(pos.x, 80);
assert.equal(pos.y, 80);
});
test('clampFloatingTriggerPosition clamps coordinates within viewport boundaries', () => {
// Coordinate beyond viewport bounds
const overflowPos = clampFloatingTriggerPosition({
x: '3000px',
y: '2000px',
width: 60,
height: 60,
viewportWidth: 1000,
viewportHeight: 800,
margin: 30
});
assert.equal(overflowPos.x, 940); // 1000 - 60
assert.equal(overflowPos.y, 740); // 800 - 60
// Coordinate with negative values clamped to min safe boundary (70, 0)
const negativePos = clampFloatingTriggerPosition({
x: '-50px',
y: '-100px',
width: 60,
height: 60,
viewportWidth: 1000,
viewportHeight: 800,
margin: 30
});
assert.equal(negativePos.x, 70);
assert.equal(negativePos.y, 0);
// Coordinate within valid range
const validPos = clampFloatingTriggerPosition({
x: '400px',
y: '300px',
width: 60,
height: 60,
viewportWidth: 1000,
viewportHeight: 800,
margin: 30
});
assert.equal(validPos.x, 400);
assert.equal(validPos.y, 300);
});
test('saveTriggerPosition, loadSavedTriggerPosition and clearSavedTriggerPosition provide robust roundtrip persistence', () => {
const mockStorage = new Map();
const storageAdapter = {
getItem: k => mockStorage.get(k) || null,
setItem: (k, v) => mockStorage.set(k, String(v)),
removeItem: k => mockStorage.delete(k)
};
// 1. Initial empty state returns null
assert.equal(loadSavedTriggerPosition(storageAdapter), null);
// 2. User drags and saves position at (949, 588)
saveTriggerPosition({ x: 949.4, y: 588.6 }, storageAdapter);
// 3. Roundtrip load retrieves exact integer coordinates
const restored = loadSavedTriggerPosition(storageAdapter);
assert.deepEqual(restored, { x: 949, y: 589 });
// 4. Clamping restored coordinates in 1920x1080 viewport stays strictly at (949, 589)
const clamped = clampFloatingTriggerPosition({
x: restored.x,
y: restored.y,
width: 60,
height: 60,
viewportWidth: 1920,
viewportHeight: 1080
});
assert.equal(clamped.x, 949);
assert.equal(clamped.y, 589);
// 5. Clear storage flushes memory
clearSavedTriggerPosition(storageAdapter);
assert.equal(loadSavedTriggerPosition(storageAdapter), null);
// 6. Dirty legacy dock coordinates (< 70px) are rejected and purged automatically
storageAdapter.setItem('anomalous_trigger_pos_v3', JSON.stringify({ x: 0, y: 449 }));
assert.equal(loadSavedTriggerPosition(storageAdapter), null);
assert.equal(storageAdapter.getItem('anomalous_trigger_pos_v3'), null);
// 7. Attempting to save coordinates inside sidebar dock is ignored
saveTriggerPosition({ x: 50, y: 300 }, storageAdapter);
assert.equal(storageAdapter.getItem('anomalous_trigger_pos_v3'), null);
});
+135
View File
@@ -0,0 +1,135 @@
// Where a LoRA goes and how it is wired (web/modules/graph_splice.js), on a small stand-in graph.
import assert from 'node:assert/strict';
import { chainEnd, loraInsertionCapabilities, mainModelLoaders, planLoraInsertion, spliceLora } from '../web/modules/graph_splice.js';
const SLOTS = {
CheckpointLoaderSimple: [[], ['MODEL', 'CLIP', 'VAE']],
UNETLoader: [[], ['MODEL']],
CLIPLoader: [[], ['CLIP']],
CLIPTextEncode: [['CLIP'], ['CONDITIONING']],
KSampler: [['MODEL', 'CONDITIONING', 'CONDITIONING', 'LATENT'], ['LATENT']],
LoraLoader: [['MODEL', 'CLIP'], ['MODEL', 'CLIP']],
LoraLoaderModelOnly: [['MODEL'], ['MODEL']],
};
class Graph {
constructor() { this._nodes = []; this.links = {}; this.nextLink = 1; this.nextNode = 1; }
getNodeById(id) { return this._nodes.find(node => node.id === id) || null; }
getLink(id) { return this.links[id] || null; }
add(node) { node.id ??= this.nextNode++; node.graph = this; this._nodes.push(node); }
remove(node) {
node.inputs.forEach((input, slot) => input.link != null && this.unlink(input.link));
node.outputs.forEach(output => [...output.links].forEach(id => this.unlink(id)));
this._nodes = this._nodes.filter(item => item !== node);
}
unlink(id) {
const link = this.links[id];
if (!link) return;
const origin = this.getNodeById(link.origin_id);
origin.outputs[link.origin_slot].links = origin.outputs[link.origin_slot].links.filter(item => item !== id);
this.getNodeById(link.target_id).inputs[link.target_slot].link = null;
delete this.links[id];
}
}
function node(graph, type, failConnect = false) {
const [inputs, outputs] = SLOTS[type];
const item = {
type, mode: 0,
inputs: inputs.map(t => ({ type: t, link: null })),
outputs: outputs.map(t => ({ type: t, links: [] })),
connect(slot, target, targetSlot) {
if (failConnect) return null;
const g = this.graph;
if (target.inputs[targetSlot].link != null) g.unlink(target.inputs[targetSlot].link);
const id = g.nextLink++;
g.links[id] = { id, origin_id: this.id, origin_slot: slot, target_id: target.id, target_slot: targetSlot };
this.outputs[slot].links.push(id);
target.inputs[targetSlot].link = id;
return g.links[id];
},
};
if (graph) graph.add(item);
return item;
}
const source = (graph, target, slot) => {
const link = graph.getLink(target.inputs[slot].link);
return link ? `${graph.getNodeById(link.origin_id).type}#${link.origin_id}` : null;
};
// A checkpoint whose CLIP feeds both prompts.
function checkpointWorkflow() {
const g = new Graph();
const ckpt = node(g, 'CheckpointLoaderSimple');
const pos = node(g, 'CLIPTextEncode');
const neg = node(g, 'CLIPTextEncode');
const sampler = node(g, 'KSampler');
ckpt.connect(1, pos, 0); ckpt.connect(1, neg, 0); ckpt.connect(0, sampler, 0);
pos.connect(0, sampler, 1); neg.connect(0, sampler, 2);
return { g, ckpt, pos, neg, sampler };
}
{
const { g, ckpt, pos, neg, sampler } = checkpointWorkflow();
const plan = planLoraInsertion(g, ckpt, 'after', { textEncoder: true });
assert.equal(plan.clip, true);
const lora = spliceLora({ graph: g, plan, anchorNode: ckpt, insertedNode: node(null, 'LoraLoader') });
assert.equal(source(g, lora, 0), `CheckpointLoaderSimple#${ckpt.id}`);
assert.equal(source(g, lora, 1), `CheckpointLoaderSimple#${ckpt.id}`);
for (const prompt of [pos, neg]) assert.equal(source(g, prompt, 0), `LoraLoader#${lora.id}`, 'both prompts move behind the LoRA');
assert.equal(source(g, sampler, 0), `LoraLoader#${lora.id}`);
// The next one goes after the LoRA already there; a model-only LoRA leaves CLIP alone.
assert.equal(chainEnd(g, mainModelLoaders(g)[0], 'MODEL'), lora);
const plan2 = planLoraInsertion(g, lora, 'after', { textEncoder: false });
const lora2 = spliceLora({ graph: g, plan: plan2, anchorNode: lora, insertedNode: node(null, 'LoraLoaderModelOnly') });
assert.equal(source(g, sampler, 0), `LoraLoaderModelOnly#${lora2.id}`);
assert.equal(source(g, pos, 0), `LoraLoader#${lora.id}`);
}
{
// A UNet with its own CLIP loader: the CLIP line comes from the CLIP loader.
const g = new Graph();
const unet = node(g, 'UNETLoader');
const clip = node(g, 'CLIPLoader');
const pos = node(g, 'CLIPTextEncode');
const neg = node(g, 'CLIPTextEncode');
const sampler = node(g, 'KSampler');
clip.connect(0, pos, 0); clip.connect(0, neg, 0); unet.connect(0, sampler, 0);
pos.connect(0, sampler, 1); neg.connect(0, sampler, 2);
const plan = planLoraInsertion(g, unet, 'after', { textEncoder: true });
const lora = spliceLora({ graph: g, plan, anchorNode: unet, insertedNode: node(null, 'LoraLoader') });
assert.equal(source(g, lora, 0), `UNETLoader#${unet.id}`);
assert.equal(source(g, lora, 1), `CLIPLoader#${clip.id}`);
assert.equal(source(g, neg, 0), `LoraLoader#${lora.id}`);
assert.equal(planLoraInsertion(g, unet, 'after', { textEncoder: false }).clip, false);
}
{
// Before a sampler: only its own model input moves; CLIP through the prompts wired into it.
const { g, ckpt, pos, neg, sampler } = checkpointWorkflow();
const caps = loraInsertionCapabilities(g, sampler);
assert.equal(caps.before.supported, true);
assert.equal(caps.after.supported, false); // a sampler has no MODEL output
const plan = planLoraInsertion(g, sampler, 'before', { textEncoder: true });
assert.equal(plan.clip, true);
const lora = spliceLora({ graph: g, plan, anchorNode: sampler, insertedNode: node(null, 'LoraLoader') });
assert.equal(source(g, sampler, 0), `LoraLoader#${lora.id}`);
assert.equal(source(g, pos, 0), `LoraLoader#${lora.id}`);
assert.equal(source(g, neg, 0), `LoraLoader#${lora.id}`);
assert.equal(source(g, lora, 1), `CheckpointLoaderSimple#${ckpt.id}`);
}
{
// A connection that fails leaves the graph as it was.
const { g, ckpt, pos, sampler } = checkpointWorkflow();
const plan = planLoraInsertion(g, ckpt, 'after', { textEncoder: true });
const broken = node(null, 'LoraLoader', true);
assert.throws(() => spliceLora({ graph: g, plan, anchorNode: ckpt, insertedNode: broken }), /Failed to connect/);
assert.equal(g._nodes.includes(broken), false);
assert.equal(source(g, pos, 0), `CheckpointLoaderSimple#${ckpt.id}`);
assert.equal(source(g, sampler, 0), `CheckpointLoaderSimple#${ckpt.id}`);
assert.equal(planLoraInsertion(g, pos, 'after').supported, false); // a prompt has no MODEL output
}
console.log('graph_splice: ok');
+120
View File
@@ -0,0 +1,120 @@
// The LoRA nodes a combo puts its LoRAs in (web/modules/lora_nodes.js), on fake nodes.
import assert from 'node:assert/strict';
import {
POWER_LORA, addPowerLoraRows, chainLoras, installedLoraNodes, loraNodeKind, nativeLora, powerLoraRows,
} from '../web/modules/lora_nodes.js';
globalThis.LiteGraph = {
registered_node_types: {
LoraLoader: { nodeData: { input: { required: { lora_name: [['Flux\\a.safetensors', 'b.safetensors']] } } } },
[POWER_LORA]: {},
},
};
// Offered only where installed: rgthree is here, Easy-Use is not.
assert.deepEqual(installedLoraNodes().map(kind => kind.id), ['core', 'rgthree']);
assert.equal(loraNodeKind(undefined).id, 'core');
assert.equal(loraNodeKind('nonsense').id, 'core');
assert.equal(nativeLora('Flux/a.safetensors'), 'Flux\\a.safetensors'); // ComfyUI's own spelling
assert.equal(nativeLora('elsewhere/c.safetensors'), 'elsewhere/c.safetensors');
const INPUTS = {
LoraLoader: ['model', 'clip'], LoraLoaderModelOnly: ['model'], [POWER_LORA]: ['model', 'clip'],
'easy loraStack': ['optional_lora_stack'], 'easy loraStackApply': ['lora_stack', 'model', 'optional_clip'],
};
function fakeGraph() {
const links = [];
const made = [];
const node = (type) => {
const item = {
type, size: [300, 100], widgets: [], inputs: (INPUTS[type] || []).map(name => ({ name })),
connect(out, target, input) { links.push([this.type, out, target.type, target.inputs[input]?.name]); return true; },
};
if (type === POWER_LORA) {
item.addNewLoraWidget = (lora) => {
const widget = { name: `lora_${item.widgets.length + 1}`, value: { on: true, lora: lora ?? null, strength: 1, strengthTwo: null } };
item.widgets.push(widget);
return widget;
};
}
if (type === 'easy loraStack') {
item.widgets = ['toggle', 'mode', 'num_loras', ...Array.from({ length: 10 }, (_, i) => `lora_${i + 1}_name`)].map(name => ({ name, value: null }));
}
return item;
};
const add = (type, relX, relY) => {
const item = node(type);
made.push({ type, relX, relY, node: item });
return item;
};
return { links, made, add, source: node('CheckpointLoaderSimple') };
}
const options = (graph, loras, clipSlot = 1) => ({ add: graph.add, source: graph.source, clipSlot, loras, relX: 350, step: 350,
setPath: (node, path) => node.widgets.push({ name: 'lora_name', value: path }) });
// ComfyUI's own: a chain, each LoRA's MODEL and CLIP into the next.
let graph = fakeGraph();
let result = chainLoras(loraNodeKind('core'), options(graph, ['a.safetensors', 'b.safetensors']));
assert.deepEqual(graph.made.map(item => [item.type, item.relX]), [['LoraLoader', 350], ['LoraLoader', 700]]);
assert.equal(result.relX, 700);
assert.equal(result.clipSlot, 1);
assert.equal(graph.links.length, 4);
// A UNet: model-only loaders, no CLIP line.
graph = fakeGraph();
result = chainLoras(loraNodeKind('core'), options(graph, ['a.safetensors'], null));
assert.deepEqual(graph.made.map(item => item.type), ['LoraLoaderModelOnly']);
assert.equal(result.clipSlot, null);
// No LoRAs: the loader itself feeds the prompts.
graph = fakeGraph();
result = chainLoras(loraNodeKind('rgthree'), options(graph, []));
assert.equal(result.last, graph.source);
assert.equal(graph.made.length, 0);
// Power Lora Loader: one node, a row per LoRA in ComfyUI's spelling, model and CLIP by name.
graph = fakeGraph();
result = chainLoras(loraNodeKind('rgthree'), options(graph, ['Flux/a.safetensors', 'b.safetensors']));
assert.equal(graph.made.length, 1);
assert.deepEqual(result.last.widgets.map(widget => widget.value.lora), ['Flux\\a.safetensors', 'b.safetensors']);
assert.deepEqual(graph.links, [['CheckpointLoaderSimple', 0, POWER_LORA, 'model'], ['CheckpointLoaderSimple', 1, POWER_LORA, 'clip']]);
assert.equal(result.clipSlot, 1);
// Easy-Use: twelve LoRAs make two stacks (ten and two), the second feeding the first, applied on the model.
graph = fakeGraph();
const twelve = Array.from({ length: 12 }, (_, i) => `l${i + 1}.safetensors`);
result = chainLoras(loraNodeKind('easy'), options(graph, twelve));
assert.deepEqual(graph.made.map(item => item.type), ['easy loraStack', 'easy loraStack', 'easy loraStackApply']);
const [first, second] = graph.made.map(item => item.node);
assert.equal(first.widgets.find(w => w.name === 'num_loras').value, 10);
assert.equal(second.widgets.find(w => w.name === 'num_loras').value, 2);
assert.equal(second.widgets.find(w => w.name === 'lora_2_name').value, 'l12.safetensors');
assert.ok(graph.made[1].relY > graph.made[0].relY); // stacked, not on top of each other
assert.deepEqual(graph.links, [
['easy loraStack', 0, 'easy loraStack', 'optional_lora_stack'],
['easy loraStack', 0, 'easy loraStackApply', 'lora_stack'],
['CheckpointLoaderSimple', 0, 'easy loraStackApply', 'model'],
['CheckpointLoaderSimple', 1, 'easy loraStackApply', 'optional_clip'],
]);
assert.equal(result.last.type, 'easy loraStackApply');
// A Power Lora Loader's rows read in order (a row with no LoRA kept as null) and made again.
const saved = { type: POWER_LORA, widgets: [
{ name: 'lora_1', value: { on: true, lora: 'a.safetensors', strength: 0.7, strengthTwo: null } },
{ name: 'header', value: { toggled: true } },
{ name: 'lora_3', value: { on: false, lora: 'b.safetensors', strength: 1, strengthTwo: 0.5 } },
{ name: 'lora_4', value: { on: true, lora: null, strength: 1 } },
{ name: '➕ Add Lora', value: undefined },
] };
const rows = powerLoraRows(saved);
assert.deepEqual(rows, [
{ on: true, lora: 'a.safetensors', strength: 0.7, strengthTwo: null },
{ on: false, lora: 'b.safetensors', strength: 1, strengthTwo: 0.5 },
{ on: true, lora: null, strength: 1, strengthTwo: null },
]);
assert.deepEqual(powerLoraRows({ type: 'LoraLoader', widgets: saved.widgets }), []);
graph = fakeGraph();
const fresh = graph.add(POWER_LORA, 0, 0);
addPowerLoraRows(fresh, rows);
assert.deepEqual(fresh.widgets.map(widget => widget.name), ['lora_1', 'lora_2', 'lora_3']); // renumbered as rgthree does
assert.deepEqual(fresh.widgets.map(widget => widget.value), rows);
console.log('lora_nodes: ok');
+61
View File
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { renderDetailedNodeCards } from '../web/modules/material_inspector.js';
// Minimal DOM double: exercise allocation and toggle/copy handlers without a browser dependency.
class Element {
constructor(tag) {
this.tagName = tag;
this.children = [];
this.listeners = {};
this.dataset = {};
this.classList = { toggle() {} };
this.open = false;
}
appendChild(child) { this.children.push(child); return child; }
append(...children) { this.children.push(...children); }
prepend(...children) { this.children.unshift(...children); }
setAttribute() {}
querySelector(selector) { return this.children.find(child => child.className?.split(' ').includes(selector.slice(1))) || null; }
addEventListener(event, callback) { (this.listeners[event] ||= []).push(callback); }
toggle(open) { this.open = open; this.listeners.toggle?.forEach(callback => callback()); }
}
globalThis.document = { createElement: tag => new Element(tag) };
globalThis.window = { anomalous_browser_lang: 'zh' };
const descendants = node => [node, ...node.children.flatMap(descendants)];
const byClass = (node, className) => descendants(node).filter(child => child.className === className);
let propertiesRead = 0;
const blocks = Array.from({ length: 2000 }, (_, index) => ({
node_id: index, type: 'KSampler', widgets_values: [123, 'fixed', 20, 7, 'euler', 'normal', 1],
get properties() { propertiesRead++; return { long: 'x'.repeat(16000) }; },
}));
blocks.unshift({ node_id: 'prompt', type: 'CLIPTextEncode', widgets_values: [' exact prompt\n'] });
const list = renderDetailedNodeCards(new Element('main'), blocks);
assert.equal(list.children.length, 2001);
assert.equal(list.children.filter(card => card.open).length, 1);
assert.equal(byClass(list, 'anomalous-material-node-parameter-content').length, 1);
assert.equal(propertiesRead, 0, 'closed nodes must not render/format their properties');
assert.equal(byClass(list.children[0], 'anomalous-material-prompt-body')[0].textContent, ' exact prompt\n');
const sampler = list.children[1];
sampler.toggle(true);
assert.equal(byClass(sampler, 'anomalous-material-node-parameter-content').length, 1);
assert.equal(propertiesRead, 1);
sampler.toggle(false);
sampler.toggle(true);
assert.equal(propertiesRead, 1, 'reopening must reuse the rendered content');
assert.equal(byClass(sampler, 'anomalous-material-node-parameter-content').length, 1);
const selectable = renderDetailedNodeCards(new Element('main'), blocks.slice(0, 2), { selectable: true });
assert.equal(selectable.children.filter(card => card.open).length, 0, 'workbench selection cards stay closed');
assert.equal(byClass(selectable, 'anomalous-material-node-parameter-content').length, 0);
const copy = byClass(list.children[0], 'anomalous-material-copy-prompt-btn')[0];
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: {
clipboard: { writeText: async () => { throw new Error('denied'); } },
} });
await copy.onclick({ stopPropagation() {} });
assert.equal(copy.textContent, '复制失败,请重试');
navigator.clipboard.writeText = async value => assert.equal(value, ' exact prompt\n');
await copy.onclick({ stopPropagation() {} });
assert.notEqual(copy.textContent, '复制失败,请重试');
console.log('material inspector: 2001 cards, one initial body, lazy toggles and clipboard feedback OK');

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