Compare commits

...
201 Commits
Author SHA1 Message Date
DemonGatanjieuandClaude Opus 5.5 fcad1bf133 The default shortcut is Shift+B, not B
comfyui-browser toggles its own window on a plain b (hard-coded, no
setting), so with both installed one press opened one window and closed the
other. Shift+B keeps the B and leaves comfyui-browser alone (it checks for a
lowercase b). Tested with both plugins loaded.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:01:12 +08:00
DemonGatanjieuandClaude Opus 5.5 d2b6c40921 Merge the README network note into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:30 +08:00
DemonGatanjieuandClaude Opus 5.5 594cb70dee README: a word on the network (Civitai, Google Translate, offline scan)
Users in mainland China often have no proxy: say which parts need the
network and that an offline scan works without it.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:38:20 +08:00
DemonGatanjieuandClaude Opus 5.5 9481729c14 Models found offline no longer get their name doubled on each scan
A record inferred from the file alone names model and version after the file. With renaming on, the scan renamed it to name_name, and every later scan doubled it again; the details showed name - name. Such records are no longer renamed (physically or virtually, an earlier doubled virtual name is dropped), and a model and version of one name read once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:03:47 +08:00
DemonGatanjieuandClaude Opus 5.5 f0dca38ffb Workflows page no longer blank after opening a workflow on the canvas
Opening a workflow on the canvas hid the workspace without restoring the page under it, so
opening the browser again showed an empty page. It now only hides the browser; the Workflows
list stays in place.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:25:59 +08:00
DemonGatanjieuandClaude Opus 5.5 1e1555ff27 docs(agents): security floor from the ComfyUI-Manager review
No code from downloads or user files runs; downloads are checked by
SHA-256; routes take no arbitrary local paths; routes that write, move,
delete or go online on the user's behalf answer only this computer;
deletions go to the Recycle Bin and nothing is overwritten.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:12:21 +08:00
Claude 888e7c8dc4 docs: point to the node's INTERFACE.md only
The Claude project mirror anomalous-tts-interface.md is no longer kept.
2026-10-09 06:32:59 +00:00
Claude fe8e08d8cf fix(tts): follow Anomalous_TTS interface 13, no local paths
Anomalous_TTS no longer takes a folder or file path on this computer from
a request (ComfyUI-Manager review of the node): the storage place, other
character folders and pretrained sources are set only in its settings
file, and imported files are always uploaded.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 07:42:41 +00:00
DemonGatanjieu e6758759f2 fix(audio): eliminate browser media caching with timestamp cache-buster and HTTP no-cache headers 2026-09-23 00:10:40 +08:00
DemonGatanjieu 68ef786450 feat(audio): support companion .orig.txt for native script display in audio cards 2026-09-22 23:56:00 +08:00
DemonGatanjieu e486c8c94f fix(audio): inject PATH for ffmpeg/ffprobe, deduplicate audio stems, and convert uploads to PCM WAV 2026-09-22 23:37:28 +08:00
DemonGatanjieu 2f70ef71b6 feat(audio): add intelligent multi-lingual romanization tool (Japanese & Korean -> Latin) to voice asset uploader 2026-09-22 21:27:45 +08:00
DemonGatanjieu 28bf192b6b feat(audio): implement voice asset ingestion modal with live audio preview and automatic F5-TTS companion transcript persistence 2026-09-22 11:49:26 +08:00
DemonGatanjieu da7439af8e docs: update ARCHITECTURE.md with audio workflow loading and canvas injection 2026-09-21 11:05:52 +08:00
DemonGatanjieu c0f7320977 feat(audio): add 1-click Arona workflow loader and canvas node drop injection 2026-09-21 11:05:34 +08:00
DemonGatanjieu 53c9f1b17c fix(entry): eliminate trigger button FOUC flicker on page refresh via pre-mount positioning and smooth opacity reveal 2026-09-21 01:51:46 +08:00
DemonGatanjieu 4573e1295d fix(entry): set default trigger position to top-left safe zone (80, 80) 2026-09-21 01:39:00 +08:00
DemonGatanjieu 9c2db0f1e9 fix(entry): resolve drag persistence collapse, dock entrapment, and regular refresh cache desync 2026-09-21 01:03:15 +08:00
DemonGatanjieu 364c5aff97 fix(entry): resolve ES module caching, mount-first lifecycle, and versioned coordinate storage 2026-09-21 00:47:07 +08:00
DemonGatanjieu 5b2e59c855 fix(entry): restore missing normalization imports and flush legacy dock coords to top-right 2026-09-20 23:52:49 +08:00
DemonGatanjieu a9aa88b439 fix(entry): restore unadulterated drag persistence and position trigger in top-right corner 2026-09-20 23:47:11 +08:00
DemonGatanjieu 91ec1eb4ce fix(entry): pull trigger outside sidebar frame to safe canvas and sanitize dirty coords 2026-09-20 23:04:34 +08:00
DemonGatanjieu 6e0988d01e fix(entry): relocate floating trigger to top-right safe canvas area and harden PointerEvents persistence 2026-09-20 22:58:54 +08:00
DemonGatanjieu fecd5e6ba7 Revert "feat(materials): implement Cross-Node Parameter Protocol (CNPP)"
This reverts commit fd34bbf246.
2026-09-20 22:46:09 +08:00
DemonGatanjieu 6f8fd99e11 fix(entry): relocate floating trigger to top-left safe canvas area and auto-heal sidebar occlusion 2026-09-20 21:01:29 +08:00
DemonGatanjieu 6c66e67742 fix(entry): fix floating trigger position collapse to top-left and harden boundary clamping 2026-09-20 20:53:04 +08:00
DemonGatanjieu fd34bbf246 feat(materials): implement Cross-Node Parameter Protocol (CNPP) 2026-09-20 20:33:24 +08:00
DemonGatanjieu a9640b9e79 refactor(materials): unify prompt extraction pipeline and enforce SSOT 2026-09-20 18:17:11 +08:00
DemonGatanjieu 5381b1326c fix(materials): strictly filter out model file paths and loader blocks from prompt envelope 2026-09-20 17:25:16 +08:00
DemonGatanjieu e4112083e3 feat(materials): implement cross-node prompt injection protocol (CNPIP) for EasyUse and third-party loaders 2026-09-20 17:15:19 +08:00
DemonGatanjieu e4dcbfab28 feat(recipe): implement universal semantic compatibility for All-in-One and third-party loaders 2026-09-20 15:46:23 +08:00
DemonGatanjieu 28a24da0c1 feat(audio): complete audio sidebar, audio vault gallery, and domain navigation 2026-09-20 12:35:13 +08:00
DemonGatanjieu 531a25d578 style(audio): refine domain switcher to sleek 26px SVG button and polish Audio Studio dark aesthetic 2026-09-20 12:25:28 +08:00
DemonGatanjieu 3bb144655a feat(audio): add dual-domain mode switcher and Audio Voice Studio workspace 2026-09-20 11:54:38 +08:00
241 changed files with 31157 additions and 26162 deletions
+24
View File
@@ -0,0 +1,24 @@
name: 报告问题 / Bug report
description: 哪里坏了、报错了,或者和你想的不一样 / Something broke or did not do what you expected
title: "[Bug] "
labels: ["bug"]
body:
- type: textarea
id: what
attributes:
label: 遇到了什么问题? / What went wrong?
description: 一两句话就行,中文英文都可以。 / A sentence or two is enough, in any language.
placeholder: 比如:在模型页点扫描,进度停在 30% 就不动了。 / e.g. The scan on the Models page stops at 30%.
validations:
required: true
- type: textarea
id: more
attributes:
label: 截图或报错(可选) / Screenshots or errors (optional)
description: 截图可以直接拖进来。 / Drop screenshots here.
- type: textarea
id: environment
attributes:
label: 环境信息(可选) / Environment (optional)
description: 在插件里点 设置 → 帮助与反馈 → 复制诊断信息,粘贴到这里。 / In the plugin, Settings → Help and feedback → Copy diagnostics, then paste here.
render: text
+2
View File
@@ -0,0 +1,2 @@
# A blank issue stays possible: the plugin's feedback window opens one with what was written.
blank_issues_enabled: true
@@ -0,0 +1,13 @@
name: 提建议 / Suggestion
description: 想要的功能、哪里不顺手、希望往什么方向发展 / A feature you want, something awkward, where the plugin should go
title: "[Idea] "
labels: ["enhancement"]
body:
- type: textarea
id: idea
attributes:
label: 你希望有什么? / What would you like?
description: 随便写,一两句话就行,中文英文都可以。 / Anything goes; a sentence or two is enough, in any language.
placeholder: 比如:希望素材库能按标签筛选。 / e.g. Filter the material library by tag.
validations:
required: true
+11 -2
View File
@@ -56,13 +56,22 @@ api/notebooks/
# AI Dev Scripts & Temporary text dumps
scripts/
doctor_code.txt
# Only the Markdown of these doc folders is tracked: screenshots, logs and raw test
# data stay local (put them under .agents/).
docs/*
!docs/architecture/
docs/architecture/*
!docs/architecture/*.md
!docs/decisions/
docs/decisions/*
!docs/decisions/*.md
!docs/audits/
!docs/audits/*.md
!docs/guides/
docs/guides/*
!docs/guides/*.md
# The README's pictures (small WebP from the isolated demo ComfyUI).
!docs/images/
docs/images/*
!docs/images/*.webp
AI_SYSTEM_TEMPLATE.md
# AI Agents Directory
+51 -6
View File
@@ -3,6 +3,28 @@
适用于本插件的所有修改,包括 Gemini、Codex 和人工维护。目标是让 UX 持续改善,同时让代码职责、状态来源和验证方式保持清楚。
用户明确的当前要求优先;确需偏离本规范时,采用最小例外,在交付说明中写明原因、影响和验证。常规实现选择自行处理,不因本规范增加无必要的确认步骤。
## 0. 每次提交的硬性要求
以下几条每次提交都要做到,没有“这次改动小”的例外;其余各节是怎么做好的指引。
1. **结构和文档同步。** 按下表更新文档,与代码放在同一次提交里。新增、删除、改名、拆分源码文件一律算改了模块职责:职责清单同时是文件目录,每个源码文件都要有一行。
2. **跑结构检查。** `node tools/check_structure.mjs`,失败不提交。它检查每个源码文件都登记在架构文档里、每个前端模块都能从入口引用到、每个前端文件都能按浏览器的方式解析(`node --check` 对 .js 文件会漏掉浏览器报错的写法),并执行规模阈值(第 2 节):新文件不能一开始就超线,已超线的文件比上次提交只能变短或持平。
3. **不留旧实现。** 被取代的模块、样式、文案 key、文档说明同一次提交删掉(第 6 节)。
4. **按风险验证**(第 7 节),只提交本任务的文件,没有明确同意不推送。收尾时工作区干净,或在交付说明里列出每个有意留下的未提交文件。
5. **测试产物不进仓库。** 截图、录屏、日志、原始测试数据、审计附件放在 `.agents/`(已忽略),不用 `git add -f` 绕过忽略规则。提交的文件里不写本机路径、用户名或 `file:///` 链接,文档之间用相对链接。
6. **交付说明**写清:改了什么和为什么、验证结果和没验证到的地方、遗留问题、提交号;用了规范例外就写原因和影响。
| 改了…… | 更新 |
| --- | --- |
| 新增 / 删除 / 改名 / 拆分源码文件,模块职责 | `ARCHITECTURE.md` 职责清单,或最窄的 `docs/architecture/` 专题 |
| 数据流、接口、持久化格式、安全边界、跨系统规则 | 对应的 `docs/architecture/` 专题;系统图或阅读地图变了才改 `ARCHITECTURE.md` 入口 |
| 用户能感觉到的行为 | `CHANGELOG.md`;使用方法变了同步 `README.md` 或 `docs/guides/` |
| 长期有效的产品或架构取舍 | `docs/decisions/` |
| 反复出现的坑、严重故障 | `.agents/logs/ai_lessons.md` |
| 开发规范本身 | 只改本文件;`CLAUDE.md`、`GEMINI.md` 只做阅读入口 |
普通修复、样式微调不写进架构文档;没有变化的边界不写“本次未改变”。
## 1. 修改前先定位责任
1. 查看 `git status` 和相关差异,保留已有工作;不覆盖、回退或提交他人的无关修改。
@@ -37,7 +59,12 @@ UX 请求默认允许调整布局、交互和文案,不代表允许改变文
- 通用文件不能成为杂物箱。不要为了省一个文件,把新领域逻辑塞进 `utils`、素材详情渲染器或浏览器主对象。
- 不引入没有当前调用方的管理器、适配层、配置项、事件总线或依赖。一次性的简单逻辑可以留在其责任模块内。
**规模审查阈值:** 普通逻辑文件超过约 600 行,或单函数超过约 100 行时,新增功能前必须检查能否提取独立职责。继续扩展时,在交付说明中解释为什么仍属于同一职责。已有超大模块的小修复不要求顺手重写全文件;语言目录、数据表和样式表不按这个行数阈值拆分。不能通过空壳包装、压缩代码或碎片化文件绕过审查。
**规模阈值(只减不增):** 普通逻辑文件以 600 行为线,单函数以约 100 行为线。
- 新文件不超过 600 行;写不下说明有两种以上职责,先拆。
- 已经超过 600 行的老文件,每次改动后只能变短或持平,不能变长。要在里面加功能,先把一块独立职责搬出去,腾出行数再加。
- 不为了达标专门大拆;改到哪个文件,就顺手从它身上拆走一块,让超线文件随日常修改逐步变少。
- 语言目录、数据表和样式表不按这个行数计算。不能通过空壳包装、压缩代码、删注释或碎片化文件绕过。
- `tools/check_structure.mjs` 按上次提交比对,新文件超线或老文件变长都会失败。
## 3. 状态、关闭与异步
@@ -72,9 +99,29 @@ UX 请求默认允许调整布局、交互和文案,不代表允许改变文
- 路径与文件名在后端使用现有边界校验;不能只检查 `..`。前端校验不能替代后端校验。
- 修改模型、封面、附属文件或导入包前先完整校验和暂存。失败时保留旧数据,避免先删除旧文件再尝试保存新文件。
- 修改多文件发布流程要覆盖中途失败和冲突。单个 JSON 的原子写入不能冒充整组文件的事务。
- 大目录遍历、哈希和大量磁盘 I/O 使用已有工作线程/进程路径,不阻塞 aiohttp 事件循环。
- UI 适配不能偷偷迁移用户数据、修改全局配置或改变现有路由与存储格式。确需改协议时同步调用方、兼容策略和测试。
### 安全底线
ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalous_TTS 时逐条指出过)。没有“先这样、以后再补”的例外:
1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。
2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。
5. **用户的文件删除一律进回收站,没有回收站就拒绝;从不覆盖**(同名另存为“名字 (2)”或拒绝)。插件自己产生的临时文件(`.part`、暂存区)可以直接删。
6. **只读的接口也按第 3 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。
7. **读用户文件要有上限。** 限制读取的大小和条数;读不了或超大的文件跳过并在结果里说明,不让一个坏文件拖垮整个请求。读到的内容进页面只当纯文本(本节第二条)。
### 资源占用
ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应该几乎不占资源:
1. **用户动了才做事。** 不在后台轮询、定时扫描或预加载;确需定时的(扫描进度、跟随选中的节点)只在对应界面打开期间进行,关掉就停。
2. **耗时的活放后台。** 大目录遍历、哈希和大量磁盘 I/O 使用已有工作线程/进程路径,不阻塞 aiohttp 事件循环。
3. **数据跟着界面走。** 界面打开时才读,关掉就释放;跨次使用的缓存要小、有上限,写清楚何时失效。长列表只渲染看得到的或前几十条。
4. **图片用缩略图。** 网格和列表用缓存的缩略图,原图只在看大图或拖到画布时读取;浏览器里不常驻大量原图。
## 6. 清理旧实现
- 替换一条路径时搜索它的导入、导出、调用、字段、事件和选择器;确认无用后一起移除。
@@ -109,9 +156,7 @@ UX 请求默认允许调整布局、交互和文案,不代表允许改变文
## 8. 文档与交付
- 架构文档只记录职责、数据流、契约和关键约束。颜色、像素值及审美描述以 CSS 为准,不每次 UX 更新都往架构入口追加长篇设计说明。
- 改动架构时更新最窄的相关专题。修改规范时以本文件为唯一正文,`GEMINI.md` 只保留阅读入口,避免两套要求漂移。
- 该更新哪份文档见第 0 节的表。规范以本文件为唯一正文,避免几份要求各自漂移。
- 完成前审查差异、死代码、临时文件和空白错误;保留现有文件编码及换行风格。
- 验证后按仓库约定形成一个连贯的本地提交,只包含本任务文件;没有明确授权不推送 GitHub。
- 交付说明包含:改了什么与原因、验证结果与限制、剩余问题、本地提交。仅在触发规模阈值或规则例外时额外解释取舍。
**交付自查:** 新职责放对位置了吗?是否只有一个状态来源?每条关闭路径会释放资源吗?迟到请求会写错对象吗?旧代码和旧 CSS 清掉了吗?新文案已本地化吗?用户文件失败时还在吗?测试验证了实际行为吗?
**交付自查:** 新职责放对位置了吗?是否只有一个状态来源?每条关闭路径会释放资源吗?迟到请求会写错对象吗?旧代码和旧 CSS 清掉了吗?新文案已本地化吗?用户文件失败时还在吗?新接口会动文件或联网吗,只接受本机请求了吗?接口收了路径吗,读用户文件有上限吗?插件闲着时还占着 CPU、内存或显存吗?测试验证了实际行为吗?
+372 -87
View File
@@ -8,10 +8,13 @@ by default.
## Reading map
Before implementation, follow [AGENTS.md](AGENTS.md), the canonical development
and maintenance rules. [GEMINI.md](GEMINI.md) is the Gemini reading entry point.
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.
The prompt studio reads material prompt payloads through `web/modules/material_prompt_data.js`;
list summaries do not contain prompt bodies. See the Material Library contract below.
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
through `web/modules/material_prompt_data.js`; list summaries do not contain prompt bodies)
and Current node lists the saved values. See the material contract below.
| When changing... | Read... |
| --- | --- |
@@ -19,9 +22,8 @@ list summaries do not contain prompt bodies. See the Material Library contract b
| Browser lifecycle, UI state, localization, media, or graph edits | [`docs/architecture/frontend.md`](docs/architecture/frontend.md) |
| Update-guide content/versioning or sidebar hover labels | [`docs/architecture/update-guide.md`](docs/architecture/update-guide.md) |
| Workflow Recipes, packages, galleries, Parameter Notebooks, or prompt roles | [`docs/architecture/recipes.md`](docs/architecture/recipes.md) |
| Material Library snapshots, image parameter details, or reusable node blocks | [`docs/architecture/material-library.md`](docs/architecture/material-library.md) |
| Model Doctor, provenance hashes, missing-model recovery, or deep scanning | [`docs/architecture/model-resolution.md`](docs/architecture/model-resolution.md) |
| Browser audits, E2E functional bug reports, or verification sign-offs | [`docs/audits/README.md`](docs/audits/README.md) |
| 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) |
@@ -61,29 +63,76 @@ DOM or live LiteGraph state.
## Ownership map
This map, with the topic documents, is also the file directory: every source
module under `api/`, `web/` and `web/modules/` and each root Python file is
named with its owner. `node tools/check_structure.mjs` checks this. Styles are
covered by the `styles.css` manifest.
### Backend
- `api/config.py` owns configured paths and active model-folder types.
- `api/path_utils.py` owns containment, filename validation, and atomic JSON writes;
- `api/path_utils.py` owns containment, filename validation, atomic JSON writes, and resolving
a saved model value inside the model folders;
`api/utils.py` is a compatibility export surface.
- `api/metadata.py` owns sidecar and safetensors metadata extraction.
- `api/metadata.py` owns sidecar and safetensors metadata extraction. A model's information
has three layers, the user's first: `<model>.anomalous.json` (what the model editor set; only
the editor writes it), the scan result (`<model>.info`, or another tool's `.civitai.info`
with Civitai ids; `id: -1` means inferred from the file) and the file itself. Metadata
reports `info_source` (`civitai` / `local` / empty) and `user_fields`. A cover is Civitai's
only while it is byte-identical to `<model>.civitai_bak.*`; any other cover is the user's,
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/models.py` is a compatibility facade.
- `api/scanner.py` and `scraper.py` own scan orchestration and enrichment.
`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/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".
`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`).
`api/kept_images.py` says what each output image was kept as, for the gallery's star: the
Workflow Recipe, combo or saved prompt that names it as its source image.
`api/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.
- `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.
integrity receipts; `api/recipes.py` is the HTTP facade. `api/recipe_constants.py`
holds the size and count limits shared by recipes, parameters and materials.
- `api/recipe_packages.py` owns bounded inspect-stage-commit package handling.
- `api/parameters.py` owns Parameter Notebook persistence and lookup.
- `api/notebooks.py` owns Prompt Note persistence and recoverable legacy copying.
- `api/notebooks.py` owns combo (搭配, formerly Prompt Note) persistence and recoverable legacy copying.
- `api/material_schema.py`, `api/material_assets.py`, and `api/material_store.py`
own curated material shaping, private assets, persistence/cache, search and
lifecycle; `api/materials.py` owns HTTP mapping and compatibility entry points.
`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.
- `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 shared with the standalone scanner.
- `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`).
- `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
plus the message shown when nothing was deleted. The plugin's own temporary files, caches
and bounded recipe history are still removed directly.
- `api/audio_catalog.py` owns the generated-audio side: the output-audio history,
temp Preview Audio results (listed, and copied into `output/audio/` on save),
deletion, and streaming files from the output and temp folders. Characters and
their reference clips belong to the Anomalous_TTS node and come from its routes.
- `api/audio_metadata.py` reads the ComfyUI `prompt` comment from FLAC and
Ogg/Opus files (read-only) and extracts the TTS node's speech, sample and seed
for the audio gallery.
- `api/image_search.py` powers the output gallery search (`gallery_images?q=`):
it reads only the PNG text chunks before the pixel data (ComfyUI prompt and
workflow, A1111 `parameters`), caches one record per image by mtime, and
@@ -92,89 +141,338 @@ DOM or live LiteGraph state.
local model files with that hash. Terms arrive as repeated `term` params, one
phrase each. `ui_search_chips.js` is the reusable search-block input (Enter or a
comma commits a block); `ui_gallery.js` places it above the gallery.
- `api/version_manager.py` owns the plugin's own version: the installed tag/branch
(`/anomalous/version`, local only), published releases fetched only on request
(GitHub releases API, falling back to `git ls-remote` tags; drafts skipped,
pre-releases never counted as latest), switching to a published tag (detached
checkout), returning to the default branch (fast-forward only), and undoing the
last switch (recorded in the ignored `.anomalous_version.local.json`). Every
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.
- `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
browser instance plus floating/topbar/menu entry behavior (with Dual-Binding PointerEvents drag capture, `lostpointercapture` fail-safe listeners, zero-drift viewport boundary clamping, flicker-free pre-mount coordinate binding and `anomalous-trigger-initializing` smooth opacity fade reveal, default safe placement in the top-left canvas area at `top: 80px; left: 80px;` gracefully avoiding the left sidebar dock, and versioned `anomalous_trigger_pos_v3` atomic JSON coordinate persistence); `entry_controls.js`
owns entry mode, trigger sizing/styling normalization, mathematical viewport-safe boundary
clamping (`clampFloatingTriggerPosition` with minimum safe boundary `minX=70` preventing left sidebar dock entrapment), clean `loadSavedTriggerPosition`/`saveTriggerPosition` storage drivers, and non-distorting coordinate validation
(`isValidSavedTriggerPosition`); `api/__init__.py` injects an aiohttp no-cache middleware (`Cache-Control: no-cache, no-store, must-revalidate`) for extension static files to eliminate browser memory/disk cache desynchronization across regular page refreshes; and `interface_settings.js` owns language and theme preferences.
- `ui_sidebar.js` creates the browser shell and folder navigation.
`ui_settings_hub.js` owns settings and model-card preferences;
`ui_toolbox.js` owns the tool catalog, fixed shortcut bar, and tool dispatch;
`ui_browser_navigation.js` owns shared panel hiding/cleanup and workspace return.
Scan-wizard launch, single-model precision scans (`triggerDirectModelScan` with strictly factual Civitai vs non-Civitai feedback reporting inferred base-model or match status directly within the bottom-right progress panel and toasts without blocking browser alerts), modal lifecycle ergonomics (backdrop click and Escape key dismissal with listener detachment, scrollable content area with sticky footer actions), post-scan frontend hash and native combo refreshes (`app.refreshComboInNodes()`, `window.anomalous_reload_hashes()`), and polling live in `ui_scan_wizard.js`; folder visibility/order lives in
`ui_folder_manager.js`; and help content lives in `ui_help.js`.
- `ui_model_sources.js` owns the Model Sources Hub, managing workflow-model and global-library source detection, Civitai/HuggingFace URL attribution, sidecar persistence, and resilient scope switching between active workflow and full local library (with cached library state preservation and reliable re-rendering).
- `ui_materials.js`, `ui_material_cards.js`, and `ui_material_application.js` own the Material Library UI, category navigation, card presentation (with grab cursor affordances, explicit drag tooltips, and polymorphic card dragging via `bindPolymorphicMaterialCardDrag`), context-aware drag guidance, relaxed third-party node prompt widget sniffing and injection, and the structured empty state onboarding blueprint guiding users through collection, canvas drag, and prompt studio mixing. Drag precedence prioritizes node hits over blank canvas drops; blank canvas drops auto-instantiate `CLIPTextEncode` nodes for prompt materials with standard colors or open full workflows.
- `ui_update_guide.js` and `update_guide_data.js` own the non-intrusive update guide modal (accessible via header button `#anomalous-update-notice-btn` and Help modal; version ID `2026-09-recipes-and-studios`), presenting a 4-step milestone walkthrough (Workflow Recipe Studio, Material Library & Prompt Studio, Model Sources Hub, and Precision Direct Scan with canvas addition) with full bilingual localization. `ui_spotlight_tour.js` provides the interactive spotlight mask tour (`startSpotlightTour`), gliding smooth focal box highlights across topbar workspaces and bottom dock actions with directional tooltip cards and keyboard navigation.
- `sidebar_actions.js` owns the sidebar bottom action hover-reveal short labels (100ms), singleton dynamic DOM tooltip bubbles (`#anomalous-sidebar-tooltip-bubble`, 600ms), click/pointerdown instant text/tooltip suppression guards, `isBottomModalOpen` tooltip occlusion guards, and anti-flicker pointer stability.
- `tool_registry.js` centralizes metadata, SVG icons (enlarged 20px crisp vector outlines with 2px stroke, #cbd5e1 contrast), and stable IDs for the 9 catalog tools (including Prompt Notes / 提示词笔记) and 2 fixed anchors (Toolbox and Settings).
- `shortcut_layout.js` provides tool layout utilities and fallbacks. The bottom shortcut bar maintains the clean fixed 4-tool setup (`scan`, `doctor`, `assistant`, `materials`) plus two anchors (`toolbox`, `settings`) housed in prominent 36px buttons with full click/active text suppression and `.is-active` toggled styling.
- `ui_toolbox.js`'s Toolbox modal strictly filters out all tools already present on the bottom bar, presenting a sleek 216px 3-row utility catalog with compact, frameless 44px tiles (providing an elevated silhouette with breathing room for catalog discovery), downward anchor caret pointing to the toolbox trigger button, 0.18s smooth spring pop-in animation, clean click action execution, and zero obstructive text or beta footers.
browser instance plus floating/topbar/menu entry behavior (pointer-capture drag,
pre-mount positioning to avoid a flash, `anomalous_trigger_pos_v3` persistence);
the unsaved default position is defined in CSS; `entry_controls.js`
owns entry mode, trigger size/style normalization, viewport clamping that keeps
the button out of the sidebar dock, and saved-position storage
(`isValidSavedTriggerPosition`); `api/__init__.py` sends `Cache-Control: no-cache`
for this plugin's static files so a normal refresh revalidates them (modules are
imported without `?v=` query strings, which would create second module
instances); and `interface_settings.js` owns language and theme preferences.
- `ui_domain_switcher.js` stores which side is active (image or audio: which list the
list column shows, which guide "!" opens); the rail's pages switch it through
`ui_shell_nav.js`. `browser.switchAudioTab` is the single entry for the audio panels
(rail pages, the audio list's own entries) and `hideAllPanels` stops audio playback.
- `audio_engines.js` owns the audio page's one speech engine, GPT-SoVITS through
the separate Anomalous_TTS node pack (docs/decisions AD-016), detected at runtime
through `/object_info/<node class>`. Characters are normalised into one
voice-group shape (`node_value` = what the node's `character` widget takes), so
cards, sidebar and the Voice-over view share one implementation. While the pack is
missing the studio shows install steps instead of cards; nothing else depends on it.
Its data comes only from the node's HTTP contract, version 13
(`/anomalous_tts/characters`, `/audio`, `/settings`, `/status`, the pretrained
download and import routes; the node repo's `docs/INTERFACE.md`); Anomalous never
reads or writes its model folders. No call names a path on the computer: the
storage place, other folders and package sources are set only in the node's
settings file (`status.settings_file`), and imported files are always uploaded. `loadGptSovitsStatus` caches the setup status
with the engine data (null for a node without `/status`, which hides setup and import). The character list is a
summary without file lists; `fetchGptSovitsCharacter` gets one character's files.
Engine presence and loaded voice groups are cached (`MAX_AGE_MS`) so re-renders
from sidebar clicks, domain switches and the studio + sidebar pair cost no
requests; `invalidateEngineCache()` drops them after a save or an added voice,
and `invalidateEngineCache({ rescan: true })` (Refresh button) also makes the node
re-read its folders (`?refresh=1`).
`ui_audio_tts_editor.js` edits a character's emotion references through that API:
it opens at once, loads the character's audio list in the background (save waits
for it), and keeps settings fields it does not know.
`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`,
`rowState` / `sectionState` for the form's colours,
`setupSummary`, `pretrainedReminder`, `missingForLanguage`, `formatSize`); no DOM.
`ui_tts_setup.js` is the GPT-SoVITS settings dialog, opened from the sidebar
footer (`setupAttention` gives that entry its dot). Missing pretrained files only get a dismissable dot
when the studio's characters need them (dismissed ids in `localStorage`; a newly
needed file brings it back); missing packages get a red one. Inside: the storage
place and other places with characters (shown, not changed), pretrained files
and package sources, packages, and the settings file where folders are changed.
It polls the status only while a download runs and it is open.
`ui_tts_import.js` is the import window. It first asks
what the user has (`mode`: `single` = one card led by its checklist, `batch` = a
drop area then one card per draft, one unfolded at a time, `add` = files for an
existing character, also opened straight from a card's "Add files"), and runs a
spotlight tour once per screen ("?" replays it). It owns the drafts (characters being built; `target` = files added to an
existing one), the unassigned tray, the characters waiting for the next batch
(a large add opens one batch; the next opens on request or once the batch is
imported), every file row (in exactly one draft's `rows`; a row keeps its own
name in `original` and gets a clash-free `name` in a draft, sent to the node as
the file's `name`), a debounced inspect per draft, and imports the ready drafts
one after another (each commit on its own, without the rows `leftOut` names;
closing discards uncommitted uploads). `draw()` rebuilds the cards on structural
changes, `refresh()` only repaints status, so typing never loses focus.
`ui_tts_import_uploads.js` sends browser files in chunks, three at a time, once
they are in a draft (tray files wait); `ui_tts_import_player.js` plays one clip
at a time for comparing (from memory).
`tts_import_groups.js` holds the pure rules: which folders are never taken
(`skipFolder` / `isPackage`: Python environments and base models anywhere, a
package's program and training folders only inside a package, never the chosen
folder), which draft a file goes to
(`groupFiles`: a folder with one character's weights and clips is that character;
other weights by stem; other files follow the character folder, the weights in
their nearest folder, then folder names or file-name prefixes; unclaimed `.list`
files go to every character), how an add splits into groups and batches
(`planGroups`, `splitBatch`), new names for files that would clash inside a
character (`clashFreeNames`), which files are left out (`leftOut`: clips outside
3–10 s and their line files), where a draft stands, and its checklist (the next
step first). `ui_tts_import_sources.js` brings files in: the browser's dialogs and
drops, all uploaded; every source gives folders starting with the chosen one,
says which folders were left out, and confirms a very large upload. `ui_tts_import_draft.js` draws a draft's card
(name in the header, steps left, the checklist with the next step's button and
the chosen weights, one line per clip with its owner picker, the first clips with
"show all", left-out files folded, line files, language) and the unassigned card
(one folded line per folder when large), and builds each row's elements once, so
they survive redraws and moves; `ui_tts_import_screens.js` holds the fixed
screens (the first question, the batch drop area, the "add more" menu) and the
tour steps. `ui_tts_file_drop.js` reads OS drops (walking dropped folders, only
files an import can use, up to 5000) for the studio, the sidebar and the
workbench.
- `audio_node_targets.js` is the single table of canvas nodes the audio studio
writes into (the character widget and the script widget of
`AnomalousTTS_CharacterSpeech`).
`planVoiceDrop` decides every character drop and names the reason for each refusal;
unlisted nodes are refused, never matched by widget name. Supporting a node
means adding one entry plus a test.
- The rail's Voices entry has two views, switched by `ui_voice_tabs.js` at the top of
each and sharing the character list: Characters (shell page `voices`) and Voice-over
(shell page `script`; `ui_shell_nav.js` keeps the rail on Voices and reopens the view
used last).
`ui_audio_studio.js` owns the Characters view: the character cards, preview playback,
tag copying, dragging a card header onto a node through the shared `bindMaterialDrag`
with `targetHint`/`rejectHint` telling the user what releasing does, and each card's
"Voice-over" button (`owner.openScript(group)`).
`ui_script_page.js` owns the Voice-over view: it loads the characters and mounts the
script director, or shows the install steps or the way to import one when there is
nothing to voice yet. A character picked in the list speaks there; the audio
gallery's "voice it again" opens a file's lines with its character
(`owner.openScript(null, { speech, subfolder })`).
`audio_script.js` holds the pure script rules: splitting, bundling
(`buildScriptPackage`, with `[take:N]` for retakes), combo value
matching, and `buildTtsPrompt` (a GPT-SoVITS script as a ComfyUI API prompt
saving to `output/audio/<character>/`).
`ui_script_director.js` owns the script director, the Voice-over view's body: one
character, an emotion chip row per line card (with the chosen emotion's reference clip;
none when the character has only its main voice), editing buttons shown on hover, and a
bundle that is generated in the view, or, under "Into your own workflow", pushed to the selected/only target node,
dragged onto one (writing the character and script widgets together, one Ctrl+Z
step) or copied. The page feeds it the voice groups after each fetch.
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).
`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
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`
(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
(the node's `replace` setting) from its card; each row can be heard as it reads
now and as replaced, through a Preview Audio run, and saving keeps every other
settings field.
`audio_tts_run.js` queues one API prompt with this page's client id and
follows it (websocket status, `/history` result, cancel = queue delete or
targeted `/interrupt`); it never touches the canvas.
- In the audio domain the header **!** opens `AUDIO_USAGE_GUIDE` (a how-to, see
`docs/guides/audio-studio.md`) instead of the visual update guide.
- `ui_audio_sidebar.js` owns the audio navigation and the active audio filter:
characters grouped by language, groups folded
in `localStorage`, a character unfolds into its clips (a click plays the clip
through its studio row, switching the studio to that character when needed), a
search box from six characters on, a red dot for characters that need a look,
the GPT-SoVITS settings entry at the bottom and file drops on a character. Canvas drags go through `audio_voice_drag.js`, shared with the
studio cards.
- `ui_audio_gallery.js` owns the generated-audio history (output folder): playback,
seeking, search, paging, download and deletion, showing the speech/voice/seed
recorded in each file, plus a section for unsaved temp previews that can be
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
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
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
Anomalous_TTS's successful write requests in a middleware, and serves
`/anomalous/activity` (a handler may attach what it changed, `request[ACTIVITY_DETAIL]`;
scans record themselves when they end); `activity_canvas.js` records canvas changes by comparing a
snapshot taken when the user presses inside Anomalous with the canvas when they
next press or type outside it (`activity_diff.js` holds the pure snapshot and
difference); `activity_log.js` is the client and the words for each entry;
`ui_activity.js` renders the activity page and the home page's recent list;
`canvas_undo.js` keeps what each canvas entry did, in full, while the page stays open,
and undoes an entry from the log while nothing has changed it since (entries that
removed nodes or rewired existing ones are never offered).
`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
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;
`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
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_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
`ui_folder_manager.js`.
`scan_progress.js` owns the scan progress panel
(`updateScanProgress` / `finishScanProgress` / `failScanProgress`): inside the scan
page while it is shown (`setScanProgressHost`), floating at the bottom right otherwise.
`shortcut_controls.js` owns the open-browser and Prompt Studio keyboard
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_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
canvas (`material_drag.js`) becomes a new group of nodes where it is dropped. Combos keep the notes' files.
- `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,
moving whole workflows kept as materials), laying an image's workflow on a canvas of its own to summarise it.
- `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).
- `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.
- `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.
- `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, `ui_model_editor.js` owns metadata editing, and
`ui_model_selector.js` owns advanced selection. `ui_gallery.js` and
`ui_gallery_detail.js` own generated-image browsing and workbench lifecycle,
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,
with stage interaction in `ui_image_stage.js` and metadata tabs in
`ui_image_inspector.js`.
- `ui_recipes.js` / `ui_recipe_detail.js`, `ui_notebooks.js`, and `ui_materials.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 Prompt Note catalog,
sidebar dual-group management (note list + floor quick jump anchor navigation with scrollspy active tracking and tooltip hints),
and persistence; `ui_notebook_editor.js` owns unfolded card editing (modularized into single-responsibility
import entrypoints), and card browsing. `ui_notebooks.js` owns the Combos workspace (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, flat material library archiving card with
clean single-icon feedback, and unified dark slim scrollbar ergonomics with complete bilingual dictionary coverage in `locales.js`), and
`notebook_canvas.js` owns LiteGraph creation. Prompt Notes are integrated as a standard tool in the Toolbox
with defensive workspace return state restoration, TDZ-safe summary initialization, and responsive empty-state fallback rendering. `ui_recipe_detail.js`
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).
`ui_materials.js` owns Material Library discovery and pagination,
`ui_material_cards.js` owns catalog cards, `ui_material_detail.js` owns the
full detail surface, and `ui_material_application.js` owns selected-node
tracking and explicit material application.
`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
to an input named positive / negative). It also plans which box takes which text and
finds a box's opposite-role partner on the same sampler. `prompt_drop.js` is the
prompt drag on the canvas: boxes outlined by role, the box under the pointer as the
target, the text a release writes previewed over it (and its partner), and a hint
saying what release writes where. `node_material_actions.js`
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_model_matching.js` owns preview resolution and explicit local replacement,
`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. `ui_recipe_catalog.js` owns recipe
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
`(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`.
- `ui_prompt_composer.js` owns the standalone Prompt Studio drawer. Its child
views are `ui_prompt_source_deck.js`, `ui_prompt_workbench.js`, and
`ui_prompt_inspector.js`. Assembly plan data, track-vs-role separation, and
cross-role tail smart-sorting are owned by `prompt_composition.js` and `prompt_studio_data.js`.
`ui_prompt_source_deck.js` owns the card preview popover with narrow bridging corridors,
differentiated hide timers, and fast dismissal when hovering or clicking library blank space.
- `ui_prompt_translator.js` owns the standalone Prompt Translator, featuring robust multilingual/Chinese node prompt extraction (`extractPromptFromNode`), real-time canvas selection synchronization (`app.canvas.onNodeSelected`), automatic prompt injection on open/docked mode, on-demand read/sync controls, guarded selection writeback across single and multi-tab workflows, compact streamlined button ergonomics preventing multi-row wrapping, elastic vertical flex textareas maximizing canvas-side vertical space, and an expanded 460px default sidebar width with automatic backward-compatible width migration. Both translator and
studio use `ui_lifecycle.js` for global listeners, request cancellation and
resize cleanup. Translation requests go through `translation_service.js`.
- `ui_dom.js` provides small DOM/JSON helpers; `material_inspector.js` owns
material-specific metadata and parameter rendering.
- `ui_doctor.js` owns diagnostics and global scans; `ui_node_assistant.js` owns
selected-node assistant history, `ui_node_model_picker.js` owns native combo
replacement, and `ui_node_presets.js` owns parameter preset rendering and
application. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js`
- `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
the prompt node last selected on the canvas or a positive / negative draft, follows the
selection and the boxes' text, and owns the studio's Undo; `ui_prompt_box_editor.js` is one
box as tags (select, weight, edit, remove, reorder, drop, type, translate to English) or text.
`prompt_tags.js` splits and joins a prompt's tags keeping their separators, and
`prompt_gloss.js` looks up and keeps the tags' meanings in the picked language. `ui_prompt_source_deck.js`
owns the card list (three built-in cards and the saved prompts), search, new-card form,
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
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.
`prompt_composition.js` holds the starter cards, composes a saved plan's text and names and sorts prompts.
- `ui_node_prompts.js` is the current-node panel's prompt boxes: role and text, with
Edit in Prompt Studio. The studio uses `ui_lifecycle.js` for global listeners, request
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
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
takes steps on mouse-up and key-up, and a drag-and-drop ends in neither.
- `model_policies.js` mirrors `model_policies.py` for the frontend: which folder
types a loader widget holds, which are never physically renamed, and which
need a workflow-carried hash before Model Check recovers them.
- `ui_dom.js` provides small DOM/JSON helpers; `material_inspector.js` owns the image
workbench's metadata and node-parameter rendering.
- `model_check.js` checks the open workflow's models 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
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
`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`
own the remaining explicit graph changes.
- `ui_model_sources.js` owns the Model Source Hub (模型来源统一中控中心), providing dual-scope
(Workflow and Library) source inspection, external platform jumping, canvas `Note` node generation,
`workflow.extra.anomalous_model_sources` metadata synchronization, automated asynchronous model
metadata resolution (`resolveWorkflowModelsMetadata`) via `/anomalous/resolve_paths_to_previews` with
local sidecar priority detection, and protected read-only link display with deliberate edit-mode
unlocking and dirty-state dynamic local persistence (hiding redundant `[Save Local]` buttons until links are modified).
- 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
models it is also kept in `workflow.extra.anomalous_model_sources`, so shared workflows carry it.
Workflow models are located here through `/anomalous/resolve_paths_to_previews`.
- `locales.js` is the shared runtime string catalog. Existing inline bilingual
UI strings remain migration debt; new strings belong in the catalog.
- `styles.css` is the ordered import manifest for `web/styles/*.css`, which own
@@ -186,7 +484,7 @@ DOM or live LiteGraph state.
These rules are intentionally summarized here and specified in the linked topic
documents.
1. **Identity is provenance, not naming.** Model Doctor may use a cryptographic
1. **Identity is provenance, not naming.** Model Check may use a cryptographic
hash, exact byte size under the allowed category policy, and target category.
Paths, filenames, display names, previews, and fuzzy similarity are never
identity evidence.
@@ -230,24 +528,11 @@ documents.
## Change and snapshot protocol
Every product-code change should end as one coherent local Git snapshot:
1. Run checks proportional to the changed behavior.
2. Update architecture documentation **only** when the change modifies a module
owner, data flow, public/internal interface contract, persistence format,
security boundary, or critical invariant.
3. When architecture changes, update the narrowest relevant topic document.
Update this entry point only if the system map, cross-system invariants, or
reading map changed.
4. Do not add an architecture entry merely to say that existing boundaries were
unchanged. Ordinary fixes belong in code, tests, Git history, and—when useful
to users—`CHANGELOG.md`.
5. Record a durable lesson in `.agents/logs/ai_lessons.md` only for a recurring
trap or a critical failure mode, not as a turn-by-turn work log.
6. Create a local commit after verification. Keep unrelated work out of the
snapshot and do not push without explicit user authorization.
7. Leave a clean worktree, or identify every intentional uncommitted file in the
handoff.
The per-commit requirements (which document to update, the structure check,
verification, snapshots and pushing) live in section 0 of [AGENTS.md](AGENTS.md)
and are not repeated here. Update this entry point only when the system map,
cross-system invariants, or reading map change; everything else goes in the
narrowest topic document.
Decision records explain enduring choices; they are not a chronological diary.
Git history is the authoritative record of implementation changes. Planning-only
+340 -173
View File
@@ -1,176 +1,339 @@
# 📈 Anomalous Model Browser Changelog
## Unreleased
## v1.57.0 Beta (Toolbox Architecture, Gallery Search & Polymorphic Drag) — 2026-09-23
### 🎨 Pure Vector Iconography Overhaul (纯代码矢量图标体系全面升级)
- **Universal Code-Native SVG Icons (全套规范化纯代码矢量 SVG 图标)**:
- Completely phased out legacy emojis and pixel icons in favor of lightweight, mathematically crisp 24x24 code-native vector SVGs (`stroke-width: 2`, rounded joins/caps).
- Redesigned the floating entry trigger orb with the **Anomalous Hypercube Core** (`1b985ba`) and frosted glass lighting effect, maintaining smooth rendering and subtle hover micro-interactions.
- Sidebar and toolbox action buttons upgraded with distinct vector geometry: 3D Asset Cube for Toolbox, Radar Pulse for Scan Wizard, Stethoscope for Model Doctor, Four-Point Spark for Node Assistant, Layered Sheets for Material Library, and Obsidian Gear for Settings.
- Eliminated icon flicker, CSS pseudo-element clipping, and alignment shifts across all display scales.
### 🔍 Multi-Dimensional Gallery Search (历史出图图库多维检索与标签块)
- **Multi-Field Real-Time Search (提示词/模型/种子/哈希多维检索)**:
- Upgraded the native output gallery header with an intelligent search bar capable of parsing prompts, model file names, exact numeric seeds, and cryptographic SHA-256 model hashes simultaneously (`1476faf`).
- Search terms are dynamically converted into visually distinct, standalone **Search Blocks (搜索标签块)** (`e077689`).
- Each search block clearly displays its matched category and provides an accessible `×` button for one-click removal, enabling flexible compound filtering without retyping.
- Backed by optimized chunk parsing (`api/image_search.py`) that reads only header metadata prior to pixel buffers, ensuring zero-lag gallery navigation.
### 🚀 Polymorphic Canvas Drag & Drop (全域多态画布拖拽架构)
- **Universal Canvas Drag (全域多态画布拖拽)**:
- Dragging workflow materials onto empty canvas automatically reconstructs and opens the full workflow.
- Dragging prompt-only materials onto empty canvas auto-creates native `CLIPTextEncode` nodes with pure prompt text, bilingual titles, and standard ComfyUI dark theme colors (`#532323` for negative / `#235327` for positive).
- Dragging materials onto existing canvas nodes prioritizes the target node, injecting matching widget values and prompt text via semantic widget sniffing while strictly filtering out model file paths and loader blocks.
- **Floating Trigger Pre-Mount & Anti-FOUC (悬浮入口预挂载防闪烁与安全吸附)**:
- Completely eliminated FOUC flicker on page refresh via pre-mount positioning and smooth opacity reveal (`53c9f1b`).
- Hardened PointerEvents persistence, safe coordinate validation, and boundary clamping to prevent trigger entrapment or collapsing to the viewport origin (`09b06d1`).
### 🧰 Toolbox Catalog & Dedicated Bottom Dock (实用工具箱与底部固定快捷栏架构)
- **Dedicated Bottom Action Bar (结构化底部快捷栏)**:
- Streamlined the lower-left sidebar into a dedicated 6-button utility bar:
1. **Toolbox (🧰 实用工具箱)**: Anchored as the primary launcher on the left to access secondary tools.
2. **Scan Wizard (🔄 扫描向导)**: Direct shortcut to launch model library scanning.
3. **Model Doctor (🩺 模型医生)**: Direct shortcut to diagnose and auto-heal missing workflow models.
4. **Node Assistant (🤖 节点助手)**: Direct shortcut for canvas node inspection, model swapping, and parameter injection.
5. **Material Library (✨ 素材库)**: Direct shortcut to manage curated material bundles and polymorphic canvas drag-and-drop.
6. **Settings (⚙️ 设置)**: Fixed anchor on the right for interface preferences.
- **Toolbox Modal Catalog (实用工具箱浮窗面板)**:
- Clicking the first icon (**🧰 实用工具箱**) opens a dedicated popup panel housing 5 secondary tools not on the main dock:
1. **Workflow Transfer Center (导入导出)**: Lossless AMB format workflow share code import and export.
2. **Prompt Studio (提示词工坊)**: Modular prompt mixer deck for lego-block prompt extraction, reordering, and assembly.
3. **Prompt Translator (翻译助手)**: Bilingual real-time English/Chinese prompt translation.
4. **Model Source Hub (模型来源)**: Online model source URL inspector with instant canvas Note node generation.
5. **Prompt Notes (提示词笔记)**: Lightweight notebook for drafting and organizing reusable prompt snippets (`workflows/anomalous_notebooks`).
- **Enhanced Sidebar Tooltips & Reveal Timing (侧边栏操作提示与动画优化)**:
- 100ms quick hover text replacement on shortcut buttons and 600ms rich singleton floating tooltip bubble (`#anomalous-sidebar-tooltip-bubble`) with bold title and muted explanation, cleanly avoiding CSS pseudo-element clipping issues.
- Clicking or pressing any action button immediately suppresses hover text and floating tooltips.
- Maintained elegant obsidian glass / dark monochromatic aesthetics without harsh or distracting bright colors.
### Navigation and update guide
- Sidebar icons reveal compact text labels on hover or keyboard focus while keeping their existing button size and actions. Detailed hints explain each tool; scan-state and language changes retain labels. Unclipped tooltips ensure explanations are fully readable.
- Update guide opens only from the persistent top-right exclamation button or Help. Dismissing never removes the entry. Sidebar labels slide upward on hover/focus; duplicate source-panel and obsolete notice styles were removed. See `docs/architecture/update-guide.md`.
### Model Source Hub (模型来源中控中心)
- Added a unified Model Source Hub under Toolbox for dual-scope (current workflow and full library) model source URL inspection, one-click browser navigation to Civitai/HuggingFace/Liblib/ModelScope, custom source editing, workflow metadata synchronization (`extra.anomalous_model_sources`), and instant canvas `Note` node generation for non-intrusive workflow documentation.
### Export availability
- Removed Prompt Studio's JSON export action and closed the Material Library file-import entry; copying prompts and local saves remain available.
- Recipe package import/export is disabled in the UI and API, including both upload inspection and import commit. The verified AMB0/AMB1 workflow Import / Export Center is restored in Toolbox as the only active import/export center. Ordinary downloads and image/workflow hash metadata are unchanged.
- Studio source cards automatically follow library additions, edits and deletions, display their origin, and cannot be deleted from the studio. Failed synchronization preserves existing cards; mixer copies remain editable.
### Material Library
- Fixed grouped material filters, Recipe scope/tag filters, and prompt studio imports using actual detail payloads. Fixed side-studio draft reset/return state and guarded recipe drops against canvas changes. Recipe model status no longer treats saved hash verification as current availability.
- Separated the lower-left Material Library from the Recipes/Prompt Notes workspace. Added canvas drag-and-drop: parameter materials replace matching node values, while the simplified prompt panel inserts text at the beginning or end, with undo.
- Material Library now opens from the lower-left control; Workflow Recipes stays at the upper right. The workflow Import / Export Center is available in Toolbox.
- Added Prompt Combinations: ordered general/specific fragments, enable switches, separate positive/negative content, and saved snapshots.
- Library cards can apply matching parameters to one selected node through the same transaction as Node Assistant, with seed preservation, native-option validation and guarded undo. Prompt combinations support explicit text replacement or append.
- Material details initially expand prompt cards only; other node parameters render on first expansion. Details omit the complete source workflow, which loads separately when opening a full snapshot. Selected-node materials never return their hidden source graph.
- Material capture supports PNG workflows, Recipe parameters and Prompt Notes. Applying reference images to LoadImage remains future work.
- Added search by name, tag, or node type, type/tag filters, and pagination. Material names and tags can be edited in place without changing their workflow.
- Saving the same source image and node selection now prompts before creating another copy. Save labels distinguish full image/workflow snapshots, node parameters, and Prompt Notes.
- Shared parameter inspection between the library and image workbench, improved keyboard card access and clipboard failure feedback, and fixed confirmation dialogs being covered by the workbench.
### Reliability
- Material browsing reuses signature-validated summaries and reads full workflows only for matching node types. Gallery pagination shares a short-lived directory snapshot; refresh and deletion invalidate it immediately.
- Prompt Notes now use the ComfyUI user directory with recoverable legacy copying, atomic saves, and visible save failures. Material saves stage their files and roll back new assets if persistence fails.
- File identity now uses full-file SHA-256 only. BLAKE3 and tensor/header digests cannot become verified file identities. Older offline metadata is reverified on demand while preserving personal notes.
### 🩺 Model Doctor
- **Manual size fallback (模型大小手动兜底)**: A unique same-size local model is now presented for explicit confirmation instead of being applied automatically. Background checks never apply it, while hash/size identity conflicts are rejected and shown in the Doctor dashboard.
### 🧰 Workflow Recipes
- **Scope-aware recipes (局部/整体配方)**: Graphs with open required connections or no runnable output are saved as partial recipes and append to the current canvas. Independently runnable graphs are saved as complete recipes and open in a new workflow canvas.
- **Optional save-time model verification (保存时可选模型核验)**: Recipe saving now performs a lightweight cached-identity check. Recognized model references missing identity are listed in the save dialog, while full SHA-256 calculation remains explicitly opt-in. This is especially useful for hash-only Model Doctor categories such as VAE and text encoders.
- **Model lifecycle consistency (模型生命周期一致性)**: Partial append remaps and merges node-scoped model Hash records, while applying a local model match updates the workflow value, structured model reference, base-model summary, and Hash index together.
- **Recipe-scoped model notes (配方模型备注)**: Each model reference can keep a bounded personal note. Package export asks whether to include these notes, and version comparison reports note and identity changes.
- **Material Library MVP (统一素材库 MVP)**: Main Gallery and Recipe gallery images now expose a non-intrusive two-column parameter detail view and can be saved as one curated bundle containing the original image, exact embedded workflow, model provenance, and reusable node blocks. Core metrics remain concise, while a lazy two-level inspector exposes every serialized node widget value, node properties, mode, and volatile-parameter marker on demand. Every node card now has a direct “Save to Library” action; checked nodes share a colocated multi-save action above the list, while the footer is reserved for the full image/workflow snapshot. One generation-settings action saves the sampler and size nodes together, and prompt cards expose topology-based positive/negative detection, manual role overrides, and direct node saving. Saved Material details preserve those overrides and allow returning to automatic detection. Workflow Recipe parameters and saved Parameter Notebooks can now publish all widget-bearing nodes, one node, or an explicit node selection as image-free Recipe Parameter materials. The ambiguous plain-text share action has been removed in favor of a visible Material Library save entry. Focused node materials feed Node Assistant without exposing a misleading full-workflow action. Saved Material cards stay compact and open a focused in-library inspector with the uncropped reference image on the left and scoped model/node parameters on the right; returning releases the full response and detailed DOM. Detail navigation bounds parsed metadata to a 16-entry LRU, cancels obsolete reads, avoids a second full-PNG browser allocation, removes duplicated widget payloads, and defers local-model preview resolution until the Models tab opens. Main Gallery keeps its existing click, drag, delete, and cover-selection behavior. The Workspace Material Library opens complete snapshots in a new canvas, while Node Assistant filters matching blocks by the selected node type, skips volatile seeds, and hands remapped Hash evidence to Model Doctor.
### ✨ Material capture and simpler pages
- 图片、配方参数与提示词笔记均可保存到素材库,保存后直接查看。提示词笔记支持整份组合或仅提示词,并可从素材载入为新笔记。
- 图片保存表单、用途调整、节点技术信息、笔记模型区与配方多选改为按需展开;配方参数页去掉重复数值,统一使用“参数方案”名称。
### ✨ Interface & Access
- **Native Configurable Shortcut (原生可配置快捷键)**: Added `Ctrl + Shift + M` as the default shortcut for opening the browser. The plugin's Interface settings show the currently active binding and open ComfyUI's native key recorder for customization. Conflict detection, overwrite confirmation, removal, reserved-key guidance, and modal guards therefore remain centralized without a separate global keyboard listener.
### 🐛 Bug Fixes
- **Prompt Studio Preview Popover Safe Zone (提示词预览浮窗全域安全区与防消失机制)**: 解决鼠标从词卡划向右侧浮窗按钮过程中浮窗提前消失的问题。引入全局动态指针安全区判定(`isPointInSafeZone`),将卡片、浮窗(向外各扩展 40px)与空间通道全部纳入安全驻留区,光标移动即实时取消关闭计时;并在定时器回调中执行终审坐标校验;结合 100ms 展开防抖与 `[📌 固定]` 钉住功能,确保用户可从容点击复制与添加按钮。
- **Live Language Switching (语言即时切换)**: Changing the plugin language now immediately re-translates the open native Interface settings—including labels, tooltips, and combo choices—as well as the browser UI, without refreshing ComfyUI.
## v1.56.1 Beta (Interface Preferences & Scan Reliability) — 2026-08-26
### ✨ Interface & Access
- **Native Interface Preferences (原生界面偏好)**: Added one native ComfyUI entry-mode setting with three strictly exclusive choices: floating button, action-bar button beside the run controls, or Extensions menu only. Floating size/style controls now appear only when floating mode is selected. A colocated language preference follows ComfyUI by default or applies a plugin-only Chinese/English override, and the existing in-browser language shortcut updates that same native setting. Entry selection remains authoritative during action-bar redraws, and every entry reuses the same browser instance and open path.
### 🐛 Reliability & Scan Feedback
- **Interrupted Scan Recovery (异常扫描自动恢复)**: Closing ComfyUI during a folder or global scan no longer leaves the browser permanently locked. Versioned scan ownership records distinguish live workers from stale or legacy markers, clean interrupted state automatically, and allow an immediate retry without reinstalling the extension.
- **Real Scan Progress (真实扫描进度)**: Folder, custom-selection, and global scans now show a non-blocking localized progress panel backed by the scanner's actual folder/model counts and current filename. Reopening the browser reconnects to the running task through the existing status polling contract.
## v1.56 Beta (Model Doctor Intelligence & Smart Civitai Routing)
### 🚀 Major Improvements & Features
- **On-Demand Dynamic Hash Resolution (按需动态算哈希与智能认亲)**: Upgraded Model Doctor's fallback resolution engine. When a local candidate model matches the exact byte size of a missing reference but lacks local metadata (e.g., after renaming and deleting `.info` files), the backend dynamically calculates its SHA256 on demand. Upon cryptographic verification, the node is seamlessly resolved and an offline inferred `.info` record is saved locally.
- **Dedicated Hash Inspection Modal (🔑 查看哈希透视详情)**: Added a dedicated `🔑 查看哈希 (View Hash)` button on every model card in the Doctor Dashboard. Clicking it opens a crystal-clear inspection modal comparing the workflow-embedded provenance hash & size with local disk hash & size, featuring one-click clipboard copying.
- **Smart Civitai by-hash Routing & NSFW Domain Dispatch (C 站精准直达与 .red/.com 智能分流)**: Upgraded the "🌐 Civitai" action buttons across Model Doctor and Workflow Import. Instead of crude search bar queries, it now queries the official Civitai `by-hash` API to resolve exact Model IDs and Version IDs, automatically routing NSFW models to `civitai.red` and safe models to `civitai.com`.
- **UNET Default Scan Coverage (UNET 目录扫描补齐)**: Added `models/unet` to the default scanned model types, ensuring UNET / Diffusion Models are consistently indexed during background deep scans.
- **Parameter Notebook Renaming (参数笔记本重命名)**: Users can now rename saved parameter notebooks directly from both the recipe sidebar list and the detail heading via inline `✏️` action buttons and modal prompts, safely updating names with atomic writes without disrupting parameter provenance or canvas matching.
- **Cross-Platform Path Separator Normalization (跨平台斜杠全链路归一化)**: Fully unified Windows `\` and Unix `/` path handling across workflow serialization, hash retrieval, and node diagnostic matching, preventing lookup misses caused by cross-platform workflow exchange.
### 🐛 Bug Fixes & UX Polish
- Fixed a false-negative rejection issue where size-matching renamed models without local `.info` files were incorrectly rejected as `identity_conflict`.
- Fixed duplicate key icons on the Model Doctor Hash Inspection modal header.
- Fixed a loop duplication issue in the workflow import modal preflight inspection.
- Updated unit test suite with comprehensive coverage for dynamic hash resolution and unet folder discovery.
## v1.55 Beta (Workflow Recipes Preview)
> **Beta scope:** Workflow Recipes, Parameter Notebooks, and the recipe-powered Parameter Presets tab in Node Assistant are preview features. The Node Assistant's Actions tab—visual model replacement and LoRA insertion—is not part of this beta. Save the current canvas and back up `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` before updating, importing, restoring, deleting, or applying recipe data. The stable target for these preview features is v1.6.
### 🚀 Major Features
- **Workflow Recipes (工作流配方)**: Save a complete workflow with a cover, name, tags, notes, model references, prompts, sampler settings, safe node parameters, and a reproducibility fingerprint. Recipes append to the current canvas without replacing it.
- **Recipe Detail Workspace (配方详情)**: Added Overview, Parameter Notebook, Gallery, and Versions tabs. Long values can be expanded or copied, model requirements are shown as separate cards, and the overview avoids exposing local model paths as display names.
- **Parameter Notebooks (参数笔记本)**: Create edited parameter notes, capture a fresh note from a matching live canvas, switch between saved notes with clear feedback, delete individual notes, and apply one note back to a workflow only after the complete recipe skeleton matches. Volatile values such as seeds are intentionally ignored.
- **Node Assistant Recipe Presets (节点助手配方预设)**: The Parameter Presets tab reads same-type node values saved by Workflow Recipes. Select a node such as KSampler and apply sampler, scheduler, steps, CFG, denoise, and other safe values in one click without replacing the rest of the workflow.
- **Model Identity and Local Matching (模型身份与本地匹配)**: Recipe model cards retain hashes, size, model category, available Civitai origin names/links, and previews. Imported recipes can match another author's model references to local files without trusting their folder paths.
- **Recipe Gallery and Parameter Comparison (配方图库与参数对比)**: Find historical outputs with the same node structure, inspect embedded generation data, compare differing parameters, and promote an output image to the recipe cover.
- **Version History (版本历史)**: Recipe updates retain bounded local history. Compare meaningful changes, restore an older version safely, and preserve the current version before restoration.
- **Portable Recipe Packages (便携配方包)**: Import or export recipes with optional preview snapshots and history. Model identity hashes can be removed for privacy. Preview snapshots contain presentation images only and never include model files.
- **Conservative Prompt Roles (保守的正负提示词识别)**: Native supported prompt paths are classified without title guessing. Unknown third-party text nodes remain unclassified until the user labels them as positive, negative, shared, or ignored.
### ✨ Interface and Interaction
- Renamed the combined Notes and Recipes area to **Creative Workspace / 创作工作台**, with separate Prompt Notes and Workflow Recipes sections.
- Recipe models, LoRAs, prompt text, common parameters, and generic node controls use dedicated responsive layouts with copy and full-content expansion where needed.
- Returning from a model detail page restores the previous recipe position instead of jumping back to the top.
- The main output gallery refreshes when opened, avoiding continuous background polling.
- Beta and data-protection guidance is available in the Recipe list, the Node Assistant Parameter Presets tab, Settings Help, and README in both Chinese and English.
### 🐛 Reliability Fixes
- Fixed a cross-panel state leak where opening a model from a Recipe Overview could overlap Node Assistant, then leave the next Workflow Recipe visit with an empty list. Returning normally, switching directly to another main panel, reopening Workspace, and closing Workspace mid-return now all restore one coherent surface.
- Fixed blank Workspace/Parameter Notebook surfaces and restored the plugin trigger when frontend module errors prevented registration.
- Fixed parameter application against current ComfyUI widget callbacks and added transactional rollback when a node update fails.
- Fixed positive/negative prompt duplication and legacy role recovery while keeping ambiguous third-party prompt nodes visibly unknown.
- Fixed panel closing and canvas handoff behavior, docked notebook confirmation controls, recipe scroll restoration, long model status text, and narrow parameter layouts.
- Added backend validation and regression coverage for recipe round-tripping, linked inputs, parameter-note deletion, model matching, and prompt-role metadata.
## v1.5.1 (The Workflow Exchange & Deep Scan Update)
### 🚀 Major Features
- **Lossless Workflow Exchange (无损工作流交互)**: Added dedicated Import/Export buttons to the bottom-left of the plugin UI. This feature allows users to directly extract, share, and import pure workflow data without relying on original PNG images, completely bypassing the metadata-stripping compression used by social platforms (like WeChat or Discord) that causes workflow loss.
- **Force Overwrite Configs (强制覆盖已有配置)**: Added a new "Force Overwrite" toggle in the Scan Wizard. When enabled, the scanner will deliberately ignore existing local `.info` files and force a fresh SHA256 hash calculation and a complete metadata pull from Civitai. This is a lifesaver for repairing incorrectly matched or corrupted model profiles.
- **Model Card Settings (模型卡片设置)**: The Settings Hub now opens one focused panel for video-cover playback (`Always play` or recommended `Play on hover`) and card image quality (`Optimized thumbnail` or `Original cover`). Existing autoplay preferences remain compatible, while image-quality choice is intentionally simple and local to the browser.
# 📈 Anomalous Model Browser Changelog
### 🐛 Bug Fixes
- **Base Model Hash Misattribution**: Fixed a critical bug in the fast scanner where it would aggressively fuzzy-match any 64-character hex string in the metadata. This previously caused LoRAs (trained via Kohya) to inherit their Base Model's hash (`ss_sd_model_hash`), resulting in totally incorrect Civitai mappings. The scanner now strictly adheres to the official `modelspec.hash.sha256/blake3` standard.
- **Lossless Cover Reset**: Reset now restores the persistent `.civitai_bak.*` copy first, falls back to a bare original cover for non-Civitai models, and preserves the active preview with a visible warning when no recoverable source exists.
- **Safe Sidecar Lifecycle**: Deleting a model cleans recognized sidecars, while renaming migrates them—including the Civitai backup—to the new stem. Same-stem model files with different model extensions are never treated as sidecar garbage.
- **Bounded Sidecar I/O**: Repeated extension literals in the API and scanner were replaced with fixed tuples and exact-path checks. Rename, delete, and reset remain constant-bounded and never scan an entire model directory.
- **Reset/Rename Extension Integrity**: Fixed a reused loop variable that could change a model's extension when cover reset and physical rename were requested together.
- **Signature-Aware Metadata Cache**: Unchanged model metadata and embedded safetensors hashes are now served from bounded caches keyed by physical file signatures. Sidecar or model changes invalidate entries automatically, while defensive copies prevent cache pollution.
- **Stable Cover Caching**: Removed per-refresh random cover URLs and switched to nanosecond file-version tokens, allowing the browser to reuse unchanged images and videos while still updating changed covers immediately.
- **Responsive Large Libraries**: Folder/model disk work now runs outside the aiohttp event loop, current-folder discovery uses one `os.scandir()` inventory, stale requests are aborted, and cards render in animation-frame chunks with lazy media activation.
- **Batched Model Doctor Resolution**: Provenance-rich workflows skip a redundant full hash-cache walk, then resolve missing nodes through a type-grouped batch endpoint so each required category is walked once per repair pass. Legacy workflows still refresh the filename cache when needed. The original hash/size/type/ambiguity rules remain unchanged, with automatic fallback to the single-item endpoint.
- **Direct Preview Fast Path**: Visual replacement resolves contained relative model paths directly and performs a full library walk only for unresolved basename fallbacks.
- **Balanced Cover Memory Use**: Static grid covers default to cached 512px WebP derivatives while detail views retain the original. Derived files are stored in ComfyUI's temporary area, capped at 256 MiB, and never alter source covers. Closing the browser releases active media immediately and releases the warm card DOM after 90 seconds of inactivity.
## v1.58.2 Beta (Small fixes: Esc closes, Show in folder, insert a picture as a node) — 2026-10-13
### 💬 From a Bilibili comment (来自 B 站评论)
- **Esc closes the browser (Esc 关闭浏览器)**: Esc closes a dialog or menu first; on a page one level down (a model's details, one combo, one workflow) it goes back to the list, as the page's back button does; on any other page, Workflows and Combos included, it closes the browser. The shortcut and the Extensions menu entry now close the browser when it is open. Esc in a text box still belongs to the text box.
- **Shift + B opens the browser (Shift + B 打开浏览器)**: the default shortcut is now **Shift + B** (again to close it). Ctrl + Shift + M is taken by Chrome and Edge (switch profile) before ComfyUI sees it, and B alone already opens comfyui-browser's window. A shortcut you changed yourself stays as it is; change it in ComfyUI Settings → Keybinding.
- **Dragging a gallery picture hides the browser (拖图时浏览器让开)**: while you drag a picture, the browser fades out as it does for other cards, so the canvas and the node under it take the drop.
- **Show in folder (打开所在文件夹)**: a model's details have **Show in folder** next to **Copy path**: Explorer (or Finder) opens in front of the browser with the file selected. Only on the computer ComfyUI runs on.
- **Insert a picture as a node (图片作为节点插入)**: right-click a gallery picture → **Insert as a Load Image node** gives you a Load Image node showing that picture: the browser closes and the node follows the pointer until a click puts it down (Esc cancels), like a combo's **Put on canvas**. The picture is copied into ComfyUI's input folder, as when you drop a file on the node (the same picture is kept once).
### ⌨️ Arrow keys in picture details (查看参数里方向键切图)
- In a picture's details, **← / →** (and **A / D**) switch pictures again after you ticked a node's checkbox under **All parameters**; the ticked checkbox kept the keyboard and the keys did nothing until you clicked elsewhere. Typing in a text box still keeps the arrows for the text.
### 🌐 English interface (英文界面)
- Picture details no longer show Chinese in the English interface: the step count ("20 steps"), **Show model** on the Models tab, the copied-JSON note and the empty-tab notes.
- Counts read right for one: "Changed 1 thing on the canvas", "1 model", "1 file" instead of "1 things", "1 models", "1 files".
- The voice guides name the node as English ComfyUI shows it: **Character Speech (GPT-SoVITS)**.
## v1.58.1 Beta (Security: only this computer can change things) — 2026-10-12
### 🔒 Other devices can look, not change (别的设备只能看,不能改)
- When ComfyUI is started with `--listen`, other devices on your network can open it. Until now they could also use most of Anomalous Model Browser's actions: delete or rename models, save and delete workflows, combos and prompts, start downloads and scans, export a backup of all your data, translate with your translation key, change settings, switch the plugin's version. Now every change answers only the computer ComfyUI runs on; other devices can still browse models, pictures and workflows.
- A window opened from another device shows **View only** next to the page title; pressing a button that changes something there says why it did nothing.
- A web page that reaches ComfyUI through another name for this computer, or a proxy, counts as another device too.
## v1.58.0 Beta (New Layout, Keep & Reuse, Prompt Studio, Model Check, Voices) — 2026-10-09
### 🧭 New layout (新布局)
- **Icon rail (左侧图标栏)**: every page now has one place on the left: Home, Activity, Models, Gallery, Workflows, Combos, Prompts, then Voices and Audio for the audio side, then scan, Model Check and Current node, with settings at the bottom. It replaces the tabs at the top, the image/audio switch and the tool bar under the folder list.
- **Folder list opens and closes per page (列表按页面收放)**: click the current page's icon again, or the button at the top left, to hide or show its list; each page remembers your choice. Pages without a list (Home, Gallery) give the space to the content. In a narrow or docked window the list starts closed and opens as a drawer against the rail, over a dimmed page; picking a folder or character, pressing the dimmed page or Esc closes it.
- **Home page (主页)**: "What would you like to do?" with a card per task (find a model, fix missing models, give a character a voice, look through outputs, keep workflows, use a combo, put prompts together) and first steps (scan model folders, tour the interface, what's new). The first time you open the browser it starts here; after that it reopens the page you used last.
- The header shows the current page's name; the workflow and combo workspaces keep the rail visible, so you can switch pages without closing them first.
- **No more toolbox (工具箱拆掉了)**: every tool now opens from the page it belongs to. Workflow share codes (import / export) are the ⇅ button at the top right of **Workflows** (where a disabled import button used to sit); **Prompt Studio** opens from **Prompts** on the rail (Prompt Notes became **Combos**, a page of their own); **Model Sources** is at the end of the type chips on **Models**, and Model Check has **Where to download** for the open workflow's models; translation is on the prompt boxes in **Current node**. The rail keeps scan, Model Check and Current node.
- **No more Material Library page (素材页拆掉了)**: it held four unrelated things, so each went where it is used. **Prompts** on the rail opens Prompt Studio beside the canvas, where your saved prompts are (and can be renamed, deleted and dragged onto the canvas); the browser comes back when you close the studio, and the second keyboard shortcut (Ctrl+Shift+L) now opens the studio too. Saved node values are in **Current node** when you select a node of their kind, which can now delete them. Recent results are the gallery's ☆, combos have their own page, and whole workflows are recipes. The recipe page's buttons that copied parameter sets into the library are gone (Current node lists recipe sets already), and saving nodes from an image's details says where they went. Nothing is deleted: your saved prompts and values stay in the same files.
- **A settings page (设置页)**: the gear opens a page instead of a small pop-up with a second dialog inside: Look (view mode, atmosphere, interface scale as a slider, theme, language), Model cards and memory (thumbnails or originals, video covers, the thumbnail cache with its size and an Empty button), Model folders, Workflows (model fingerprints), Opening and window (where the browser opens from, reset layout) and Help and feedback. Each setting says what it does and what it costs; Back returns to the page you came from.
- **One look everywhere (配色统一)**: pages made at different times used their own greys (bluish slate here, neutral grey there) and their own accent (sky blue, violet, indigo). They now all take the Home page's colours: neutral surfaces, the same text greys and one accent for links and selection, and they follow the Abyssal Scarlet theme too (the Material Library, Prompt Studio, voices and several dialogs used to stay blue-grey in it). The workflow share-code dialogs lost their blue and orange buttons for the usual ones. Status colours and prompt-role colours are unchanged.
- **Easy feedback (反馈更方便)**: Home ends with "Found a problem, or have an idea?" and Settings has the same under Help and feedback. **Report a problem** and **Suggest something** open a small window with one text box (a sentence or two, any language); **Submit on GitHub** then opens a new issue with it written in, titled from its first words, in the browser's language, so only Submit is left to press. The environment (plugin and ComfyUI versions, Python and PyTorch, GPU and memory, OS and browser, the page you were on; never a file path or ComfyUI's command line) is attached folded at the end when ticked, and can be looked at first; **Copy diagnostics** copies it for anywhere else. The repository's issue forms ask just what went wrong, with optional screenshots.
- **No more manual pop-up (使用手册弹窗去掉了)**: the long help text, which had fallen behind the window (it still called recipes and materials "in testing"), is gone; each page now explains itself, Home lists what you can do, and **!** shows what changed. Settings → Help and feedback has **Interface tour**, **What's new**, **Full guide** (the README on GitHub, at its Chinese half in Chinese) and the feedback buttons. The README's backup note no longer calls these features a test.
- **Model folders, readable (模型文件夹设置更好懂)**: folder management is a view of the settings page instead of a pop-up. Grouping is two plain choices, **By model type** or **By folders on disk** (it was "Category mode / Physical mode"); the types shown on the Models page come first, each with its usual name (Checkpoint, Diffusion…) and folder name, a switch and ↑↓ to order them; the ones that are off follow, and the many folders ComfyUI registers that few people use (configs, datasets…) fold away. Each change applies at once; there is no Save and reload button any more.
- **Up-to-date guides (说明跟上新界面)**: the **!** "What's new" guide and the README now describe the rail, Home, keeping results, Prompt Studio, Model Check and character voices instead of the old tabs and bottom bar.
- **The tour follows how you work (导览按使用顺序讲)**: the interface tour now goes the way the pages are used: the rail as one line (pick models, keep a good result, use it again), the Gallery's ☆ and where each kind of keep goes, Workflows (with share codes), Combos, Prompt Studio, Current node, then scanning, Model Check, the **!** guide and Settings. Steps that still described translating on Current node and the old scan wizard are rewritten, and the tour's text follows the interface language like the rest of the browser.
### ⭐ Keep and use again (收藏与复用)
- **Keep a result from the gallery (图库里点 ☆ 收藏)**: every image in the gallery has a ☆. It asks what to keep, and says where each goes: **The whole workflow** (a Workflow Recipe on the Workflows page, with the picture as its cover), **Its combo** (the main model, LoRAs and positive prompt, on the Combos page, ready to put on the canvas; models this computer does not have are left out and the message says how many) or **Just the prompts** (positive and negative, in your saved prompts). No name is asked: it is named after the prompt. Kept images show ★; a row already kept shows ✓ and opens it, and its **Remove** (取消收藏) moves what was kept to the Recycle Bin after asking, so the star goes back to ☆. The image workbench's **Keep** opens the same menu (its name-and-tags form is gone). The Material Library's Recent shelf, which did the same for whole workflows only, is gone: the gallery lists the same images. Deleting an image from the gallery asks in the usual dialog instead of over the picture.
- **Whole workflows live on the Workflows page (完整工作流都在“工作流”页)**: a result kept whole (now with the gallery's ☆, or from the image workbench) is a Workflow Recipe with that picture as its cover (it was a material), so versions, parameter sets and Model Check work on it. The Material Library keeps the pieces (nodes, parameters, prompts) and lost its Workflow category. Whole workflows kept as materials before show on the Workflows page as "N more in the library — Move them here"; moving saves each as a recipe and leaves the material file where it is, just no longer listed. Recipes that are not whole workflows are called Modules everywhere (the filter said "Sub-graph").
- **Prompt Notes are now Combos, a page of their own (提示词笔记改名“搭配”,有了自己的页面)**: a note was always a main model, a few LoRAs and a prompt kept together, so it is called a combo and sits on the rail as **Combos**, with a card on Home. The page shows every combo as a card with its model's cover, its LoRAs and the start of its prompt, a search box and **New combo**; a card opens the combo to edit, and **‹ All combos** goes back. The old notes column with its quick-jump list is gone. Your notes are the same files and show up as combos; prompts saved from notes before still show in the Material Library and can be saved as a new combo.
- **Put a combo on the canvas (搭配一键放到画布)**: **Put on canvas** on a combo's card or in its editor (formerly Send to canvas, under More) lays the combo out as a group of nodes wired together: its main model loader, its LoRAs and a positive and a negative prompt node; the group follows the pointer until you click, and Esc takes it off again (nothing is logged or left behind). The positive node holds the combo's prompt (left empty when it has none, rather than leaving the prompt nodes out) and the negative node gets Prompt Studio's common negative card. A combo card can also be dragged onto the canvas, and the group is built where it is dropped. Nothing already on the canvas is changed (to change an existing node's values, use Current node). A new group from a UNet combo connects its prompt nodes to the canvas's CLIP loader when there is exactly one, and says so once the group is down (the message no longer pops up while the group still follows the pointer, where closing it put the group down).
- **Recipe detail, plainer (配方详情更清爽)**: the models a recipe uses are one list: preview, name, the node, whether this computer has it under the saved name, size, your note and its download page. The recipe's own "match local model / apply match" buttons, the identity badges, the advanced blocks with paths and hashes, the origin editor and the recipe fingerprint are gone: when the workflow opens, Model Check finds renamed or moved models and puts them in, and saving the recipe again keeps that (the old version is kept). The status says "Every model is on this computer" or "N models missing" with what happens next. The save dialog and the empty page speak plainly (no more "unclosed node chain" or "self-healing"), and the parameters page lost its save-to-library button on every node (pick nodes with "select some parameters" instead).
- **Shared workflows say where their models come from (分享的工作流带上模型来源)**: saving a workflow or an image now also stores each model's download link (the one you set in the model editor, else its Civitai page) unless the workflow already keeps one, and drops links of models it no longer uses, so after Model Check swaps in your own model, what you export names your file, fingerprint and link while the workflow you opened stays as it was. GGUF, .pth and .sft models get fingerprints too. The share-code export says how many models carry a fingerprint, warns when some have none, and can leave the links out.
### ✍️ Prompts and Current node (提示词与当前节点)
- **Prompt Studio edits the node itself (提示词工坊直接改节点)**: no more assembly board, "write to node" step or positive / negative tabs. Select a prompt node and its prompt boxes appear in the studio, positive and negative together, as tags: click one to make it stronger or weaker, double-click to edit it, drag to reorder, × to remove. Every change goes straight into the node, and the studio's **Undo** (or Ctrl+Z on the canvas) takes it back; line breaks in your prompt stay where they were. Type in a box and press Enter to add tags: Chinese, Japanese, Korean or any other language is translated to English first, and tags the box has already are not added twice; a box that holds words that are not English offers to translate them. The meaning menu in the top bar puts each tag's meaning under it in Simplified or Traditional Chinese, Japanese, Korean, Russian, Spanish, French, German or Portuguese; **Text** shows the whole text instead. With no prompt node selected you write a draft, which can be saved or dragged onto the canvas by its ⠿ handle; the studio keeps editing the last prompt node until you select another one or press the ✕ beside its name. A clicked card goes into the box of its role (a negative card never lands in a positive box). The six starter cards are down to three (quality, common negative, hands and anatomy), your saved prompts are under **My prompts** with their first words showing, and the studio no longer reads every saved prompt again every 30 seconds, only new ones.
- **Saved prompts are managed in Prompt Studio (存过的提示词在提示词工坊里管理)**: the studio's library cards for prompts you saved used to be read-only ("managed by the source material"). Their preview now has **Rename** and **Delete** (the whole saved prompt, positive and negative together, to the Recycle Bin), and any card can be dragged out of the studio onto the canvas: onto a prompt box it fills that box (outlined by role, with the text previewed before you let go, one Ctrl+Z or Undo in the message to take it back), onto empty canvas it makes a prompt node.
- **Prompt boxes found by what they are (提示词框按节点定义识别)**: dropping or writing a prompt (material library, Prompt Studio, translator) now finds a node's prompt boxes by ComfyUI's own definition (multiline text inputs) instead of guessing from names, so a filename or "value" box no longer takes a prompt and third-party prompt nodes are recognised. Whether a box is positive or negative follows its wiring to the sampler, not the node's title: a CLIP Text Encode connected to the negative input gets the negative prompt even if it is still called "CLIP Text Encode". Prompt Studio's "take prompts from the selected node" also reads the roles right again (it could not follow links in current ComfyUI).
- **See where a dragged prompt goes (拖提示词前先看清楚落在哪)**: while you drag a prompt from the material library, every prompt box on the canvas is outlined (green for positive, red for negative, grey when it is not wired to a sampler) and the box under the pointer lights up. The label next to the pointer says exactly what releasing does, e.g. "Release: into #6's positive prompt box; the negative prompt box on the same sampler (#7) too". Drop on a box and that box is filled; the matching negative (or positive) box of the same sampler gets the other half of the material. On a node with several prompt boxes, point at the one you mean instead of letting it guess. A short message confirms what was filled; the library's receipt undoes it. Dropping a prompt plan on empty canvas used to create an empty prompt node; it now creates a positive and, when the plan has one, a negative node with their text.
- **See it before you let go (拖的时候先看到结果)**: dragging a prompt over a prompt box shows, in that box and in the opposite box on the same sampler, the text releasing would put there; move away and they go back. Dragging saved values over a node of their kind lists what would change, such as "steps 12 → 28, cfg 4.5 → 5.5", or says nothing would change.
- **Undo what you dropped (拖错了可以撤回)**: anything Anomalous writes on the canvas (a material dropped on a node, a prompt put in a box, values applied in Current node, a model swapped or a LoRA inserted, a recipe or combo sent to the canvas) is now one step of ComfyUI's own **Ctrl+Z**; before, a drop was not, and Ctrl+Z could undo the step before it instead. After a drop, the message at the top right has **Undo** and stays while the pointer is on it. Dropping a prompt on empty canvas can be undone the same way. In **Activity**, canvas changes made since the page was opened have **Undo** while nothing has changed them since; entries that removed nodes or rewired existing ones only record.
- **Current node (当前节点, formerly Node Assistant)**: the panel no longer splits into "actions" and "parameter sets" tabs; it is one page with the model actions on top and **Parameters** below. Parameters lists every saved set of values for this kind of node in one list — from the material library and from recipes' parameter sets, the same values saved twice shown once — and each entry says exactly what it would change on the selected node ("steps: 20 → 30"). **Apply these N** changes only those; seeds and model files stay (change the model above), and the receipt undoes it. **Save these values** keeps the node's current values in the material library, so the next node of that kind can use them.
- **Prompts on Current node (当前节点列出提示词框)**: Current node now lists the selected node's prompt boxes, each marked positive or negative by its wiring, and **Edit in Prompt Studio** opens them in the studio, where they are edited and translated. The separate Prompt Translator window is gone.
### 🩺 Model Check (模型检查)
- **Model Check, formerly Model Doctor, redone (模型检查,原“模型医生”,重做)**: when a workflow opens with models this computer does not have, a bar over the canvas says how many, and how many are here under another name or folder; **Put in** swaps those in and **Show** opens the doctor. The doctor page lists what needs you first and says why in plain words (same file found elsewhere, same size only, not on this computer, no fingerprint saved in the workflow), with a button for each: put in, use it, pick one yourself, or look it up on Civitai; the models that are fine fold away. Opening the doctor no longer changes the workflow by itself, and a model put in by the doctor or its picker no longer stays marked red by ComfyUI. The "auto-detect on startup" switch (it did nothing) and the doctor's own deep hash scan are gone: the check hashes same-size files itself, and the page points to the scan page when unscanned models could be the missing ones.
- **The Model Check bar can be turned off (模型检查横条可以关掉)**: the bar now starts with "Model Check:" so it is clear it comes from the browser, not ComfyUI, and has **Don't show again**, which stops checking workflows when they open and says where to turn it back on. The same switch is in Settings → Workflows ("Check for missing models when a workflow opens", on by default); with it off, run Model Check from the rail when you want it.
- **A model put in by Model Check stays put (模型检查换上的模型不会再变回去)**: ComfyUI was not told about the change, so reloading the page right after **Put in** (or Ctrl+Z) brought back the old, missing name and its red mark. Each put-in is now one step of ComfyUI's history, saved like any edit of your own.
- **Model Check finds renamed models by fingerprint again (按指纹找回改名的模型)**: when a workflow's model was renamed or moved and no scan had hashed the file yet, Model Check failed to hash the same-size files itself (an error in the background) and called the model missing. It now hashes them and finds it.
- **Model Check is honest about same-name files (模型检查不再把同名当成同一个文件)**: for a workflow without fingerprints, a missing LoRA or checkpoint with a file of the same name on this computer was listed as "the same file under another name or folder" and counted among those to put in at once. It is now a candidate you take or leave on its row: "same name", or "same name and exactly the same size, very likely the one" (listed first). VAE, CLIP and other foundation components still go by fingerprint only. Model Check now also checks GGUF models and finds upscale models moved to another folder. Each one carries a level next to its name: ●●● sure (same fingerprint), ●●○ likely, ●○○ maybe, ○○○ unclear / does not match / not found.
### 🔍 Scanning and model information (扫描与模型信息)
- **Scan page (扫描页)**: the rail's scan button and Home's "Scan model folders" open a page instead of the old seven-switch dialog. It shows how many models are matched on Civitai, not matched, and not scanned yet; one button scans the new ones, with progress in the page (it runs in the background, so the window can be closed). Offline scanning, renaming files on disk, fetching everything again, picking models and the Civitai API key are in **Scan settings**; the two risky switches turn themselves off after each scan. The "record model fingerprints in workflows" switch moved to Settings → Model settings.
- **The scan page in plain words (扫描页说人话)**: it says what a scan does (the file's fingerprint is looked up on Civitai), and why a model was not found: not published on Civitai (voice, upscale and detection models mostly come from HuggingFace or GitHub), trained or merged by you, or changed after download; it works as usual, and you can add its download link. A model Civitai does not know that is no known image model says it may be a voice or upscale model. Model files in other formats (.pth, .ckpt, .bin…), which scans never read, are listed under **Other formats** with why, so they are no longer a mystery.
- **A tidier scan page (扫描页更清爽)**: the scan page now holds only the counts, the scan button, the last scan in one card and a card saying how the next scan goes. **Not found**, **Not scanned yet**, the other formats and the last scan's models each open their own page with **‹ Back to scan**, a line on why those models are there, one button for all of them (scan these / look them up again) and the list; a model opened from it comes back to the same list where you were. **Show the result** after a scan opens the last scan's page. The old fold-out **Advanced** is now **Scan settings**, a page of its own like the lists: its card on the scan page shows how the next scan goes (all models or the picked ones, online or offline, renaming files…, with the ones that change files in yellow), and the page asks one question per card, which models as two choices side by side and each option with a switch, the ones that change files or take long marked so, the online-only ones under their own heading (faded for an offline scan).
- **Unmatched models can be looked up again (没匹配上的模型可以重新查找)**: a model that Civitai did not know, or that was scanned while Civitai could not be reached, used to stay "local only" for good unless every model was fetched again (which also replaced your own covers). **Look up the N unmatched models again** retries just those, keeps your covers, and reuses the saved hash when the file is unchanged, so it takes seconds instead of re-hashing gigabytes.
- **Which models, and what a scan did (是哪些模型、扫描做了什么)**: on the scan page, **Not matched** and **Not scanned yet** open a list of those models, with their type and folder; unmatched ones say why (Civitai does not have the file, Civitai could not be reached then, or it was an offline scan) and the base model inferred. Each row opens the model (Back returns to the scan page), scans it or looks it up again, or searches Civitai for its name. After a scan the page shows its result: when, how long, how many matched, inferred, failed, covers downloaded and files renamed, and each model it changed with what happened; the progress box says the same and, when it floats over the canvas, has **Show the result**. A model's card scan looks an unmatched model up again and its message says what it found.
- **Online and offline in one scan (联网和离线在同一次扫描里)**: a scan asks Civitai first and infers the base model from the file for what Civitai does not know, as before; now it also remembers why. When Civitai stops answering (two models in a row), the rest of the scan infers from the files right away instead of waiting out every timeout, and the next online scan looks those models up again by itself, as it does for models from an offline scan. Models Civitai answered "not found" for wait for **Look up again**. A model that fails (a locked file, say) no longer ends the scan of the others; it is listed as failed with the reason.
- **Your edits come first (你改的永远优先)**: names, notes and download links you set in the model editor now have a file of their own next to the model (`<model>.anomalous.json`) that scans never write, and they win over Civitai's information, which wins over what is inferred from the file. Edits saved by earlier versions keep working. Editing a model that was never scanned used to make it look scanned, so scans skipped it for good; it is now scanned like any new model.
- **Covers you chose are never replaced (你换的封面不会被替换)**: a cover counts as Civitai's only while it is the very image the scan downloaded; any other cover is yours. **Fetch scanned models again** now keeps your covers (and names and notes), updates only Civitai's own, and asks before it starts.
- **Where a model's information comes from (信息来源标注)**: cards look as before for models matched on Civitai; a base model inferred from the file shows as "≈ SDXL" with a dashed outline, and a model not scanned yet says so, each with a tooltip on what to do. The detail header adds the source (Civitai, inferred from the file, not scanned) and which fields you edited.
- **Faster rescans (重复扫描更快)**: a scan no longer downloads every cover and Civitai model page again for models whose information is already on disk; only new or missing ones go to the network.
- **Model types at the top (模型页顶部类型标签)**: the models page starts with a chip per model type (Checkpoint, LoRA, VAE…) and how many models it holds; a chip shows that whole type, subfolders included, so the grid no longer looks like it holds one folder's models. Picking a folder in the list narrows the grid to that folder, shown as a chip with ✕ that goes back to the whole type. The browser remembers the type you picked last.
- **Model sources on the Models page (模型来源进了模型页)**: the Sources chip at the end of the model types now shows, in place of the cards, where each model is downloaded, for **This workflow** or **All models**, filtered by with / without a link. Every link can be typed or pasted straight into its row (any site, not only Civitai), found automatically, opened and saved with one button; a link is kept in the model's own information (the same as the editor's download link) and, for this workflow's models, in the workflow too, so it travels when the workflow is shared. The list can go on the canvas as a note or to the clipboard. Model Check's **Where to download** opens it on This workflow. The separate pop-up, its lock-then-edit links and its four ways of saving are gone.
- **GGUF and .pth models show up (GGUF、.pth 模型能看到了)**: the Models page, the folder list's counts, search and model lookups only knew `.safetensors`, `.ckpt` and `.pt`, in lower case, so quantized GGUF models (Wan, Flux, Qwen…), most upscalers (`.pth`), `.sft` and `.bin` files and names like `MODEL.SAFETENSORS` were missing. They are all listed now. Scans still read only `.safetensors`; the others stay under **Not scanned yet**.
### 🗑️ Safer with your files (删除更安全)
- **Deleting goes to the Recycle Bin (删除进回收站)**: deleting a model (with its info and covers), an output image, generated audio, a recipe (with its versions and images), a material, a prompt note or a parameter note, replacing or resetting a cover, and the duplicate copy a file rename finds, all move the files to the system Recycle Bin instead of erasing them. On a USB stick or network drive, which has no Recycle Bin, nothing is deleted and the message says to delete it by hand. The confirmations say where things go. An unused route that could erase every `.civitai.info` in the library (with the names you set) is gone.
### 🪶 Lighter on memory (更省内存)
- **Model cards show up sooner (模型卡片出得更快)**: card thumbnails used to live in ComfyUI's temp folder, which ComfyUI empties on every start, so the first visit after each restart remade them all. They are now kept in ComfyUI's user folder (still at most 256 MB) and survive restarts. Opening a model type also makes the missing ones in the background, one at a time, so scrolling down finds them ready; the first screen of cards loads before the rest.
- **Video covers show a still first (视频封面先显示第一帧)**: a video cover now shows its first frame right away and downloads nothing until it plays. Playing on hover is the default now (it was always-play, though the setting called hover "recommended"), so fewer videos decode at once; "Always play" is still in Settings.
- The output gallery shows 512 px thumbnails instead of full images (cached like model cards), so scrolling through hundreds of outputs no longer fills the browser's memory and graphics memory with full-size pictures. Dragging an image to the canvas and the large viewer still use the original. When the browser has been closed for 90 seconds, its gallery images are released too, like the model grid.
### 🕘 Activity log (操作记录)
- **See what Anomalous changed (看 Anomalous 改了什么)**: the new **Activity** page on the rail lists, by day, every change the plugin made: on the canvas, which node settings changed (from → to), which nodes were added or removed, and which workflow was opened; in files, models, covers, recipes, materials, notes, presets, images, audio and imported characters. Filter by canvas or files; open a canvas entry for its details and **Find on canvas** to jump to the node. Your own edits on the canvas are not recorded as the plugin's. The home page shows the latest few.
- **More detail (记录更详细)**: a scan is recorded when it ends, with what it found and each model it changed (open one from the entry), instead of a bare "scanned models" when it started. Editing a model records each field from → to, and deleting one lists the files moved to the Recycle Bin.
- Nothing is undone by itself (canvas entries have **Undo**, see "Undo what you dropped" above). **Clear** removes the log itself, never your changes. The newest 500 entries (at most 2 MB) are kept in ComfyUI's user folder.
### 🎙️ Audio & Voice Studio (音频与声音工作台)
- **Characters and Voice-over, one page (角色和配音在同一页)**: the Script Director used to be a drawer opened by one of four equal buttons on Voices, over the character cards. Voices now has two views switched at the top, **Characters** and **Voice-over**, sharing the character list on the left: on Voice-over, clicking a character makes it the one who speaks. Each character card has **Voice-over** to go there with that character chosen; Home's "Give a character a voice" opens it too, and the rail's Voices entry reopens the view you used last. **Generate** is the big button; when it is done it says how long it took (instead of the node's count of sentences, batches and cache) and **Saved to Audio ›** opens the audio gallery. Pushing to a node, dragging onto one and copying sit below under **Into your own workflow**, and a push or drop is one Ctrl+Z step.
- **Generation settings you can read (生成设置不再挤成一团)**: language is a row of choices and speed a slider, always in view; the four sampling numbers fold under **Fine-tuning (usually left alone)**, one per row with a plain name (Candidates, Randomness…) and the node's own name under it. The side no longer scrolls sideways.
- **Calmer line cards (台词卡片更清爽)**: split, merge and delete show when the pointer is on a card; another take of a line stays in view. The ▶ that played the emotion's reference clip (easily taken for the generated line) is now a small speaker beside the emotion chips. A character with only its main voice shows no chips at all, just one line on how to add emotions. Plainer words: "Split into lines" instead of "Parse lines", "3 lines · using main voice" instead of "Bundled 3 lines".
- **Audio gallery (音频库)**: each file's lines show once (the full text was always shown under the summary), 💬 opens them on Voice-over with their character for another take, and the list on the left no longer repeats the gallery as "Audio history".
- **Generate right on the Voice-over page (配音页直接生成)**: for GPT-SoVITS characters, **Generate** runs the script in ComfyUI without touching the canvas (it waits in the queue while ComfyUI is busy), plays the result and saves it to `output/audio/<character>/`. **↻** on a line card gives just that line another take while the others come from cache; **New take of all** starts over with a new seed. Language, speed and the sampling parameters (one folded **Generation settings** line, with a hint on each) start from the character's saved defaults; when a take sounds right, **Save these settings as this character's default** keeps them for next time. The drawer is wider: the line cards get the full height with generating in a column beside them, and in a narrow or docked browser the drawer covers the studio with generating below the cards; either way only the cards scroll, however long the script is. Needs Anomalous_TTS interface 11.
- **Pronunciation table (读音表)**: **Pronunciation** on a GPT-SoVITS character card lists what the script says and what the character should read instead (`C站 → 西站`); each line can be heard as the character reads it now and as replaced, without filling the audio gallery.
- **Audio page tour (音频页遮罩导览)**: the tour offered in the audio page's "!" guide now walks through the audio page itself (import, dragging a character onto a node, emotions and pronunciation, emotion tags, Voice-over, audio gallery, GPT-SoVITS settings) instead of the image-side tour, whose buttons are not there. Tour steps whose button is not on screen are skipped.
- **Suggested folder layout (推荐的文件夹放法)**: the "many characters" import screen shows, folded, how to arrange a folder so it sorts cleanly: one folder per character, named after it, with its GPT and SoVITS models and 3–10 s clips (and same-name line files) inside.
- **Emotion tag buttons (情绪标签按钮)**: a clip's copy button now reads "Copy {happy}" and its tooltip says what pasting it into the speech text does; characters with only a main voice have no copy button, since their text needs no tag.
- **Audio page tabs (音频页顶部标签)**: the "Audio Workflows" tab is gone from the audio page; generating now happens on the Voice-over page, and saved workflows stay under **Workflows** on the image side.
- **Voice-over (配音)**: paste a script for one character; each sentence becomes a card with emotion chips, inline editing, split at cursor, merge and delete. Generate it right there (below), or push the tagged script to the selected GPT-SoVITS node or drag it onto one.
- **Audio gallery (音频库)**: lists generated audio with its speech text, voice and seed read from the file itself, supports search, and can save unsaved previews into `output/audio/`.
- **Clear drag rules (拖动规则明确)**: drag a character card's header onto a GPT-SoVITS node to set its character. The hint says what releasing will do; unsupported nodes are refused with the reason instead of being guessed.
- **Usage guide (使用说明)**: in the audio page, the header **!** opens a five-step how-to; the full guide is `docs/guides/audio-studio.md`.
- **GPT-SoVITS characters (GPT-SoVITS 角色)**: the audio page manages characters of the separate Anomalous_TTS node pack, detected at runtime; while it is missing, the page shows what it does, how to install it (ComfyUI Manager or the project page) and nothing else depends on it. The node's character menu has **Import or edit characters**, which opens this page on that character. Preview reference clips, pick a reference clip per emotion ("Edit emotions", saved through the node's API), drag a character onto the node, and write or generate scripts on the Voice-over page.
- **Audio sidebar (音频侧栏)**: characters are grouped by language in foldable groups; a character unfolds into its clips, and a click plays one. Characters drag onto nodes from the sidebar too, a red dot marks characters that need a look, a search box appears from six characters on, and files dropped on a character open **Add files** for it.
- **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: an empty studio is a drop area with **Import character**, which also sits in the toolbar. **GPT-SoVITS settings** at the bottom of the sidebar opens a dialog with where characters are kept, pretrained files and missing Python packages; missing pretrained files only get a small dot on that entry, with "don't remind me", once your characters need them, and the import form notes what the chosen language will download. **Batch import (批量导入)**: the import window takes one or many characters. It first asks what you have (one character, many characters or a package, or files for a character you already have), with three steps at the top and a spotlight tour the first time ("?" replays it). Each character card leads with a checklist that lights up the next step (GPT model, SoVITS model, clips, main voice, name) and offers its button right there; the card says how many steps are left. Drop files or whole folders (a GPT-SoVITS package too), or pick them in the browser; everything is uploaded to the node: a folder holding one set of GPT + SoVITS weights and its clips is one character named after the folder (two characters whose weights are both called `model` stay apart; `派蒙/v2` becomes "派蒙 v2"); weights in a package's weight folders pair up by name (latest epoch kept); clips and line files follow their character folder, the weights in their folder or the character their folder or file name points at; an annotation file (`.list`) no character claims goes to every character; two clips with the same name (`开心/01.wav`, `难过/01.wav`) both go in, the second as `难过_01.wav`; anything unclear waits in a card of its own, one line per folder that can be assigned at once, and is uploaded only once it is put into a character. Clips outside 3–10 s are left out with their line files. Python environments and base models (`runtime`, `pretrained_models`, `venv`…) are skipped, and a package's program and training folders (`GPT_SoVITS`, `logs`, `output`, `TEMP`, `tools`) only inside a package: your own folders called `output` or `GPT_SoVITS`, and the folder you chose or dropped, are always read; the window says which folders were skipped instead of files going missing quietly. A big add opens 20 characters (about 800 files) at a time; **Next batch** opens the rest, and it opens by itself once a batch is imported, so a huge folder does not freeze the window. Every clip has **▶** to listen while importing — uploading or unassigned — and clicking another clip switches to it for quick comparing. Long clip lists show the first 60, and a very large upload is confirmed first; uploads run three at a time. Each character is a card: one line with its name, a short status and what it holds, unfolding into its model tiles and clips; files move between characters with the picker on each row. **Import N characters** imports each ready one on its own, so one failure leaves the others alone. Status is colour-coded throughout: green ready, amber still missing something, red a failed or refused file, blue uploading; **Import** stays at the bottom. Adding to a character skips files it already has and merges annotation files (Anomalous_TTS interface 5). Characters are kept in one storage place (default `models/gpt_sovits`). It, other character folders and GPT-SoVITS packages to take pretrained files from are set in Anomalous_TTS's settings file, which the dialog names with a copy button: the node takes no folder or file path on this computer from the page, so a page cannot choose where folders are made or which files are read. Download pretrained files with progress. **Import character** builds a character from dropped or picked GPT / SoVITS weights, reference clips and annotation files: the name, SoVITS version, clip lengths and lines are filled in automatically, and everything is copied (originals stay). Drop files on a card, or use **Add files**, to add clips and emotions to an existing character. Lines are found in same-name `.txt` / `.lab` files, annotation files or, as a marked guess, the file name; a text file dropped on a clip's line box fills it. Needs Anomalous_TTS with interface version 13; file actions only work on the computer running ComfyUI.
- The first "Import character" on a fresh install no longer says the storage place cannot be written to: the voices page asks GPT-SoVITS again before refusing, instead of trusting what it heard up to a minute earlier.
- **A character voice dropped on a TTS node stays put (拖到 TTS 节点的角色声音刷新后还在)**: the same gap as above: reloading right after the drop brought back the node's previous voice, and Ctrl+Z skipped it. Every drop from the browser onto the canvas is now recorded as one history step.
### Fixes
- **Models found offline no longer get their name doubled (离线推断的模型名字不再越叠越长)**: a model Civitai had no record of (or a scan without a connection) is named after its file, as both model and version. With renaming on, a scan renamed it to name_name, and each later scan doubled it again; the details showed "name - name". Such models now keep their file name, a doubled name set by an earlier virtual rename is dropped at the next scan, and the details show the name once. Files already renamed keep their current name (scanning again no longer lengthens it).
- **The Workflows page was blank after opening a workflow (在新画布打开后再打开插件是空的)**: opening a workflow on the canvas (Open on a card or in its details) and then the browser again showed an empty page. The Workflows list is there again.
- **Language follows ComfyUI again (语言跟随 ComfyUI)**: with the language left on "Follow ComfyUI" (the default), changing ComfyUI's language now switches the browser the next time it opens; before, it kept the old language until the page was reloaded.
- **Empty list on the audio pages (音频页左边列表是空的)**: after reloading ComfyUI, a browser that reopened straight onto Voices or Audio showed an empty list column until you switched to the image side and back.
- Prompt Studio's new card form showed "cancel" instead of 取消 on its Cancel button.
- All-in-one loader recipes and cross-node prompt injection use one verified adapter table; prompt text never crosses between positive and negative.
- The Workflows and Combos workspace no longer covers the header: the Models / Gallery / Workflows tabs stay clickable while it is open, and **Esc** closes it (back to what was open before) unless a dialog is above it or a text field has focus. Closing the Prompt Translator brings back the browser it folded away.
- **Gallery pictures dragged to the canvas open their workflow again (图库拖到画布又能直接打开工作流)**: since the gallery shows small thumbnails, Chrome handed ComfyUI the thumbnail, which has no workflow in it, so only a Load Image node appeared. The original is dragged now: on an empty spot it opens the workflow, on a Load Image node it becomes that node's picture (full size, not the thumbnail).
- **The picture details' thumbnail rail keeps its place (查看参数左边的缩略图不再重新翻一遍)**: picking a picture near the end loads the next gallery page; the rail used to be rebuilt and scroll down again from the top, now the new pictures are added below.
- **Back and the tabs work on a scrolled page (页面往下滚后,返回和顶部标签点得动)**: on a combo's page and on a Workflow Recipe's details, once the content was scrolled under the header, clicks on **‹ All combos**, **← Back to recipes** and the recipe's tabs went to the model tiles or the cover hidden behind them (Chrome still counted those as being there). Both pages now give the click to what you see.
- **Picking a model keeps your place (换模型时列表不再跳回顶部)**: in Current node's model picker, choosing a model further down redrew the whole list and scrolled back to the top (the model was chosen all the same). Only the chosen card's outline changes now.
- **A combo is saved once, not once per tag (搭配不再一下存几十次)**: opening a combo's bilingual tags saved it once for nothing, then once more for every tag's translation as it came back, so a long prompt wrote the file (and the activity log) dozens of times in a second. The translations are now saved together, half a second after the last one.
- **Activity shows where a long prompt changed (操作记录能看到长提示词改了哪里)**: values are kept to 400 characters, cut from the start, so an edit late in a long prompt showed the same text before and after. A long before/after pair is now cut around its first difference.
- **Keeping a picture no longer changes the canvas (收藏图片不再改动画布上的参数)**: keeping a picture with ☆ as a workflow or a combo reads its workflow off the canvas, but when the canvas had the same workflow open (a picture dragged in, say), ComfyUI took the two for one and the canvas's values became the picture's. The workflow is now read under a name of its own.
## v1.57.3 Beta (Hotfix: Offline Scan, Prompt Workshop) — 2026-09-29
### 🔌 Offline scan and small fixes (离线扫描与小修复)
- **Offline scan stays offline (离线扫描不再联网)**: "Offline fast read" no longer sends the greyed-out rename and overwrite options, and an offline scan never fetches Civitai model pages or preview images, even for models whose `.info` came from Civitai. Scan requests without options are refused instead of starting an online scan.
- Clicking the drop zone at the end of the Prompt Workshop's assembly track focuses the card search again instead of failing silently.
- Malformed requests to translation, deletion, scanning and image-material routes get a clear 400 instead of a server error.
## v1.57.2 Beta (Hotfix: Canvas Performance, Saving Workflows) — 2026-09-27
### 💾 Saving workflows and Prompt Notes (保存工作流与提示词笔记)
- **Saving a workflow works again (保存工作流失败)**: since v1.57, **Save Current Workflow** failed with "Could not save this Workflow Recipe" for any workflow with a model loader, because splitting the backend into smaller files left the save path calling a function that was no longer imported. The same split also made model cards load full-size cover images instead of thumbnails (slow on big folders), and made editing details of a model that had moved fail.
- **Prompt Notes with UNet models (提示词笔记与 UNet 模型)**: sending a note with a UNet / diffusion model to the canvas built a graph that could not run, because its LoRAs waited for a CLIP the UNet does not have. LoRAs of a UNet now load model-only, and the note says to connect your own text encoder to the two prompt nodes. Models whose metadata names no base model (most models not downloaded from Civitai) were missing from every group; they now appear under **No base model listed**. Listing models no longer blocks ComfyUI while it reads the folders.
- Installed v1.57.2 before 2026-09-27 through the version panel? Pick v1.57.2 there once more to get this build; updating through ComfyUI Manager gets it as usual.
### ⚡ Smooth canvas dragging again (画布拖动卡顿修复)
- **Dragging and zooming the canvas no longer slows down with this plugin installed (安装插件后拖动画布掉帧)**: one stylesheet rule, which only hid the sidebar tooltip while the Toolbox or Settings popup was open, made the browser re-check the whole page every time any element's inline style changed. ComfyUI moves every text-box widget on every frame while you pan or zoom, so each frame paid for it, even with the browser closed. Measured on a 300-node workflow in ComfyUI: style work while panning drops from about 11× the no-plugin cost back to the same as without the plugin.
- **Large workflows with ComfyUI Prompt Assistant (与提示词小助手同时安装时的严重卡顿)**: the cost grew with the number of page elements, and Prompt Assistant adds a row of buttons to every text box, so together style work took about 19 ms per frame (more than a whole 60 fps frame): single-digit frame rates or freezes on big workflows. It is now back to the level of Prompt Assistant alone. The two plugins never conflicted otherwise.
- The tooltip still stays hidden while the Toolbox or Settings popup is open; the popups already hide it when they open and it does not reappear until they close.
### 🧩 Material Library with a node selected (选中节点时素材库卡片错位)
- **One apply button per card (每张卡片只有一个应用按钮)**: with a node selected, the Material Library showed every card's **Apply Scheme** button twice, each with a doubled ⚡ icon.
- **No more overlapping text (卡片文字不再重叠)**: the extra button squeezed the badges, date, node types and tags on top of each other. Cards in apply mode now get a taller row with the button at the bottom; applying works as before.
## v1.57.1 Beta (Version Panel: Update, Switch & Roll Back) — 2026-09-23
### 🏷️ Version & Updates Panel (版本号与一键更新 / 版本切换)
- **Version entry in the update guide (更新引导顶部的版本号与更新入口)**: the installed version (e.g. `v1.57.1-beta`) and a **Check for updates / switch version** button now appear at the top of the update guide (opened via the header **!** button), avoiding accidental clicks near the tool dock or crowding the header in docked/scaled layouts. Reading it is local only; nothing goes online.
- **Manual "Check for updates" (手动检查更新)**: the plugin never checks by itself. Clicking **Check for updates / switch version** opens the panel, and only the **Check for updates** button contacts GitHub. Each published release shows its notes, date and a GitHub link; pre-releases are hidden by default and are never offered as the latest version.
- **One-click update to published releases (一键更新到正式发布版本)**: updates only target releases published on GitHub, not in-progress commits, so what you install is a released build.
- **Switch or roll back (切换 / 回退版本)**: any published release can be installed from the list. Rolling back warns that older versions may not read settings or recipes saved by newer ones; nothing is deleted, and it all works again after returning.
- **Back to latest & undo (回到最新版 / 撤销上次切换)**: **Back to latest** returns to the default branch and pulls the newest code; **Undo last switch** returns to exactly where you were before. Versions older than v1.57.1 have no panel, so from those use **Update** for this plugin in ComfyUI Manager to come back.
- **Safe by default (安全保护)**: every switch is refused while plugin files have local modifications (the files are listed), and a local branch with unpublished commits is never overwritten.
- **Restart (重启生效)**: after a switch the panel offers to restart ComfyUI through ComfyUI Manager; without Manager it asks you to restart by hand.
## v1.57.0 Beta (Toolbox Architecture, Gallery Search & Polymorphic Drag) — 2026-09-23
### 🎨 Pure Vector Iconography Overhaul (纯代码矢量图标体系全面升级)
- **Universal Code-Native SVG Icons (全套规范化纯代码矢量 SVG 图标)**:
- Completely phased out legacy emojis and pixel icons in favor of lightweight, mathematically crisp 24x24 code-native vector SVGs (`stroke-width: 2`, rounded joins/caps).
- Redesigned the floating entry trigger orb with the **Anomalous Hypercube Core** (`1b985ba`) and frosted glass lighting effect, maintaining smooth rendering and subtle hover micro-interactions.
- Sidebar and toolbox action buttons upgraded with distinct vector geometry: 3D Asset Cube for Toolbox, Radar Pulse for Scan Wizard, Stethoscope for Model Doctor, Four-Point Spark for Node Assistant, Layered Sheets for Material Library, and Obsidian Gear for Settings.
- Eliminated icon flicker, CSS pseudo-element clipping, and alignment shifts across all display scales.
### 🔍 Multi-Dimensional Gallery Search (历史出图图库多维检索与标签块)
- **Multi-Field Real-Time Search (提示词/模型/种子/哈希多维检索)**:
- Upgraded the native output gallery header with an intelligent search bar capable of parsing prompts, model file names, exact numeric seeds, and cryptographic SHA-256 model hashes simultaneously (`1476faf`).
- Search terms are dynamically converted into visually distinct, standalone **Search Blocks (搜索标签块)** (`e077689`).
- Each search block clearly displays its matched category and provides an accessible `×` button for one-click removal, enabling flexible compound filtering without retyping.
- Backed by optimized chunk parsing (`api/image_search.py`) that reads only header metadata prior to pixel buffers, ensuring zero-lag gallery navigation.
### 🚀 Polymorphic Canvas Drag & Drop (全域多态画布拖拽架构)
- **Universal Canvas Drag (全域多态画布拖拽)**:
- Dragging workflow materials onto empty canvas automatically reconstructs and opens the full workflow.
- Dragging prompt-only materials onto empty canvas auto-creates native `CLIPTextEncode` nodes with pure prompt text, bilingual titles, and standard ComfyUI dark theme colors (`#532323` for negative / `#235327` for positive).
- Dragging materials onto existing canvas nodes prioritizes the target node, injecting matching widget values and prompt text via semantic widget sniffing while strictly filtering out model file paths and loader blocks.
- **Floating Trigger Pre-Mount & Anti-FOUC (悬浮入口预挂载防闪烁与安全吸附)**:
- Completely eliminated FOUC flicker on page refresh via pre-mount positioning and smooth opacity reveal (`53c9f1b`).
- Hardened PointerEvents persistence, safe coordinate validation, and boundary clamping to prevent trigger entrapment or collapsing to the viewport origin (`09b06d1`).
### 🧰 Toolbox Catalog & Dedicated Bottom Dock (实用工具箱与底部固定快捷栏架构)
- **Dedicated Bottom Action Bar (结构化底部快捷栏)**:
- Streamlined the lower-left sidebar into a dedicated 6-button utility bar:
1. **Toolbox (🧰 实用工具箱)**: Anchored as the primary launcher on the left to access secondary tools.
2. **Scan Wizard (🔄 扫描向导)**: Direct shortcut to launch model library scanning.
3. **Model Doctor (🩺 模型医生)**: Direct shortcut to diagnose and auto-heal missing workflow models.
4. **Node Assistant (🤖 节点助手)**: Direct shortcut for canvas node inspection, model swapping, and parameter injection.
5. **Material Library (✨ 素材库)**: Direct shortcut to manage curated material bundles and polymorphic canvas drag-and-drop.
6. **Settings (⚙️ 设置)**: Fixed anchor on the right for interface preferences.
- **Toolbox Modal Catalog (实用工具箱浮窗面板)**:
- Clicking the first icon (**🧰 实用工具箱**) opens a dedicated popup panel housing 5 secondary tools not on the main dock:
1. **Workflow Transfer Center (导入导出)**: Lossless AMB format workflow share code import and export.
2. **Prompt Studio (提示词工坊)**: Modular prompt mixer deck for lego-block prompt extraction, reordering, and assembly.
3. **Prompt Translator (翻译助手)**: Bilingual real-time English/Chinese prompt translation.
4. **Model Source Hub (模型来源)**: Online model source URL inspector with instant canvas Note node generation.
5. **Prompt Notes (提示词笔记)**: Lightweight notebook for drafting and organizing reusable prompt snippets (`workflows/anomalous_notebooks`).
- **Enhanced Sidebar Tooltips & Reveal Timing (侧边栏操作提示与动画优化)**:
- 100ms quick hover text replacement on shortcut buttons and 600ms rich singleton floating tooltip bubble (`#anomalous-sidebar-tooltip-bubble`) with bold title and muted explanation, cleanly avoiding CSS pseudo-element clipping issues.
- Clicking or pressing any action button immediately suppresses hover text and floating tooltips.
- Maintained elegant obsidian glass / dark monochromatic aesthetics without harsh or distracting bright colors.
### Navigation and update guide
- Sidebar icons reveal compact text labels on hover or keyboard focus while keeping their existing button size and actions. Detailed hints explain each tool; scan-state and language changes retain labels. Unclipped tooltips ensure explanations are fully readable.
- Update guide opens only from the persistent top-right exclamation button or Help. Dismissing never removes the entry. Sidebar labels slide upward on hover/focus; duplicate source-panel and obsolete notice styles were removed. See `docs/architecture/update-guide.md`.
### Model Source Hub (模型来源中控中心)
- Added a unified Model Source Hub under Toolbox for dual-scope (current workflow and full library) model source URL inspection, one-click browser navigation to Civitai/HuggingFace/Liblib/ModelScope, custom source editing, workflow metadata synchronization (`extra.anomalous_model_sources`), and instant canvas `Note` node generation for non-intrusive workflow documentation.
### Export availability
- Removed Prompt Studio's JSON export action and closed the Material Library file-import entry; copying prompts and local saves remain available.
- Recipe package import/export is disabled in the UI and API, including both upload inspection and import commit. The verified AMB0/AMB1 workflow Import / Export Center is restored in Toolbox as the only active import/export center. Ordinary downloads and image/workflow hash metadata are unchanged.
- Studio source cards automatically follow library additions, edits and deletions, display their origin, and cannot be deleted from the studio. Failed synchronization preserves existing cards; mixer copies remain editable.
### Material Library
- Fixed grouped material filters, Recipe scope/tag filters, and prompt studio imports using actual detail payloads. Fixed side-studio draft reset/return state and guarded recipe drops against canvas changes. Recipe model status no longer treats saved hash verification as current availability.
- Separated the lower-left Material Library from the Recipes/Prompt Notes workspace. Added canvas drag-and-drop: parameter materials replace matching node values, while the simplified prompt panel inserts text at the beginning or end, with undo.
- Material Library now opens from the lower-left control; Workflow Recipes stays at the upper right. The workflow Import / Export Center is available in Toolbox.
- Added Prompt Combinations: ordered general/specific fragments, enable switches, separate positive/negative content, and saved snapshots.
- Library cards can apply matching parameters to one selected node through the same transaction as Node Assistant, with seed preservation, native-option validation and guarded undo. Prompt combinations support explicit text replacement or append.
- Material details initially expand prompt cards only; other node parameters render on first expansion. Details omit the complete source workflow, which loads separately when opening a full snapshot. Selected-node materials never return their hidden source graph.
- Material capture supports PNG workflows, Recipe parameters and Prompt Notes. Applying reference images to LoadImage remains future work.
- Added search by name, tag, or node type, type/tag filters, and pagination. Material names and tags can be edited in place without changing their workflow.
- Saving the same source image and node selection now prompts before creating another copy. Save labels distinguish full image/workflow snapshots, node parameters, and Prompt Notes.
- Shared parameter inspection between the library and image workbench, improved keyboard card access and clipboard failure feedback, and fixed confirmation dialogs being covered by the workbench.
### Reliability
- Material browsing reuses signature-validated summaries and reads full workflows only for matching node types. Gallery pagination shares a short-lived directory snapshot; refresh and deletion invalidate it immediately.
- Prompt Notes now use the ComfyUI user directory with recoverable legacy copying, atomic saves, and visible save failures. Material saves stage their files and roll back new assets if persistence fails.
- File identity now uses full-file SHA-256 only. BLAKE3 and tensor/header digests cannot become verified file identities. Older offline metadata is reverified on demand while preserving personal notes.
### 🩺 Model Doctor
- **Manual size fallback (模型大小手动兜底)**: A unique same-size local model is now presented for explicit confirmation instead of being applied automatically. Background checks never apply it, while hash/size identity conflicts are rejected and shown in the Doctor dashboard.
### 🧰 Workflow Recipes
- **Scope-aware recipes (局部/整体配方)**: Graphs with open required connections or no runnable output are saved as partial recipes and append to the current canvas. Independently runnable graphs are saved as complete recipes and open in a new workflow canvas.
- **Optional save-time model verification (保存时可选模型核验)**: Recipe saving now performs a lightweight cached-identity check. Recognized model references missing identity are listed in the save dialog, while full SHA-256 calculation remains explicitly opt-in. This is especially useful for hash-only Model Doctor categories such as VAE and text encoders.
- **Model lifecycle consistency (模型生命周期一致性)**: Partial append remaps and merges node-scoped model Hash records, while applying a local model match updates the workflow value, structured model reference, base-model summary, and Hash index together.
- **Recipe-scoped model notes (配方模型备注)**: Each model reference can keep a bounded personal note. Package export asks whether to include these notes, and version comparison reports note and identity changes.
- **Material Library MVP (统一素材库 MVP)**: Main Gallery and Recipe gallery images now expose a non-intrusive two-column parameter detail view and can be saved as one curated bundle containing the original image, exact embedded workflow, model provenance, and reusable node blocks. Core metrics remain concise, while a lazy two-level inspector exposes every serialized node widget value, node properties, mode, and volatile-parameter marker on demand. Every node card now has a direct “Save to Library” action; checked nodes share a colocated multi-save action above the list, while the footer is reserved for the full image/workflow snapshot. One generation-settings action saves the sampler and size nodes together, and prompt cards expose topology-based positive/negative detection, manual role overrides, and direct node saving. Saved Material details preserve those overrides and allow returning to automatic detection. Workflow Recipe parameters and saved Parameter Notebooks can now publish all widget-bearing nodes, one node, or an explicit node selection as image-free Recipe Parameter materials. The ambiguous plain-text share action has been removed in favor of a visible Material Library save entry. Focused node materials feed Node Assistant without exposing a misleading full-workflow action. Saved Material cards stay compact and open a focused in-library inspector with the uncropped reference image on the left and scoped model/node parameters on the right; returning releases the full response and detailed DOM. Detail navigation bounds parsed metadata to a 16-entry LRU, cancels obsolete reads, avoids a second full-PNG browser allocation, removes duplicated widget payloads, and defers local-model preview resolution until the Models tab opens. Main Gallery keeps its existing click, drag, delete, and cover-selection behavior. The Workspace Material Library opens complete snapshots in a new canvas, while Node Assistant filters matching blocks by the selected node type, skips volatile seeds, and hands remapped Hash evidence to Model Doctor.
### ✨ Material capture and simpler pages
- 图片、配方参数与提示词笔记均可保存到素材库,保存后直接查看。提示词笔记支持整份组合或仅提示词,并可从素材载入为新笔记。
- 图片保存表单、用途调整、节点技术信息、笔记模型区与配方多选改为按需展开;配方参数页去掉重复数值,统一使用“参数方案”名称。
### ✨ Interface & Access
- **Native Configurable Shortcut (原生可配置快捷键)**: Added `Ctrl + Shift + M` as the default shortcut for opening the browser. The plugin's Interface settings show the currently active binding and open ComfyUI's native key recorder for customization. Conflict detection, overwrite confirmation, removal, reserved-key guidance, and modal guards therefore remain centralized without a separate global keyboard listener.
### 🐛 Bug Fixes
- **Prompt Studio Preview Popover Safe Zone (提示词预览浮窗全域安全区与防消失机制)**: 解决鼠标从词卡划向右侧浮窗按钮过程中浮窗提前消失的问题。引入全局动态指针安全区判定(`isPointInSafeZone`),将卡片、浮窗(向外各扩展 40px)与空间通道全部纳入安全驻留区,光标移动即实时取消关闭计时;并在定时器回调中执行终审坐标校验;结合 100ms 展开防抖与 `[📌 固定]` 钉住功能,确保用户可从容点击复制与添加按钮。
- **Live Language Switching (语言即时切换)**: Changing the plugin language now immediately re-translates the open native Interface settings—including labels, tooltips, and combo choices—as well as the browser UI, without refreshing ComfyUI.
## v1.56.1 Beta (Interface Preferences & Scan Reliability) — 2026-08-26
### ✨ Interface & Access
- **Native Interface Preferences (原生界面偏好)**: Added one native ComfyUI entry-mode setting with three strictly exclusive choices: floating button, action-bar button beside the run controls, or Extensions menu only. Floating size/style controls now appear only when floating mode is selected. A colocated language preference follows ComfyUI by default or applies a plugin-only Chinese/English override, and the existing in-browser language shortcut updates that same native setting. Entry selection remains authoritative during action-bar redraws, and every entry reuses the same browser instance and open path.
### 🐛 Reliability & Scan Feedback
- **Interrupted Scan Recovery (异常扫描自动恢复)**: Closing ComfyUI during a folder or global scan no longer leaves the browser permanently locked. Versioned scan ownership records distinguish live workers from stale or legacy markers, clean interrupted state automatically, and allow an immediate retry without reinstalling the extension.
- **Real Scan Progress (真实扫描进度)**: Folder, custom-selection, and global scans now show a non-blocking localized progress panel backed by the scanner's actual folder/model counts and current filename. Reopening the browser reconnects to the running task through the existing status polling contract.
## v1.56 Beta (Model Doctor Intelligence & Smart Civitai Routing)
### 🚀 Major Improvements & Features
- **On-Demand Dynamic Hash Resolution (按需动态算哈希与智能认亲)**: Upgraded Model Doctor's fallback resolution engine. When a local candidate model matches the exact byte size of a missing reference but lacks local metadata (e.g., after renaming and deleting `.info` files), the backend dynamically calculates its SHA256 on demand. Upon cryptographic verification, the node is seamlessly resolved and an offline inferred `.info` record is saved locally.
- **Dedicated Hash Inspection Modal (🔑 查看哈希透视详情)**: Added a dedicated `🔑 查看哈希 (View Hash)` button on every model card in the Doctor Dashboard. Clicking it opens a crystal-clear inspection modal comparing the workflow-embedded provenance hash & size with local disk hash & size, featuring one-click clipboard copying.
- **Smart Civitai by-hash Routing & NSFW Domain Dispatch (C 站精准直达与 .red/.com 智能分流)**: Upgraded the "🌐 Civitai" action buttons across Model Doctor and Workflow Import. Instead of crude search bar queries, it now queries the official Civitai `by-hash` API to resolve exact Model IDs and Version IDs, automatically routing NSFW models to `civitai.red` and safe models to `civitai.com`.
- **UNET Default Scan Coverage (UNET 目录扫描补齐)**: Added `models/unet` to the default scanned model types, ensuring UNET / Diffusion Models are consistently indexed during background deep scans.
- **Parameter Notebook Renaming (参数笔记本重命名)**: Users can now rename saved parameter notebooks directly from both the recipe sidebar list and the detail heading via inline `✏️` action buttons and modal prompts, safely updating names with atomic writes without disrupting parameter provenance or canvas matching.
- **Cross-Platform Path Separator Normalization (跨平台斜杠全链路归一化)**: Fully unified Windows `\` and Unix `/` path handling across workflow serialization, hash retrieval, and node diagnostic matching, preventing lookup misses caused by cross-platform workflow exchange.
### 🐛 Bug Fixes & UX Polish
- Fixed a false-negative rejection issue where size-matching renamed models without local `.info` files were incorrectly rejected as `identity_conflict`.
- Fixed duplicate key icons on the Model Doctor Hash Inspection modal header.
- Fixed a loop duplication issue in the workflow import modal preflight inspection.
- Updated unit test suite with comprehensive coverage for dynamic hash resolution and unet folder discovery.
## v1.55 Beta (Workflow Recipes Preview)
> **Beta scope:** Workflow Recipes, Parameter Notebooks, and the recipe-powered Parameter Presets tab in Node Assistant are preview features. The Node Assistant's Actions tab—visual model replacement and LoRA insertion—is not part of this beta. Save the current canvas and back up `workflows/anomalous_recipes` plus `workflows/anomalous_parameters` before updating, importing, restoring, deleting, or applying recipe data. The stable target for these preview features is v1.6.
### 🚀 Major Features
- **Workflow Recipes (工作流配方)**: Save a complete workflow with a cover, name, tags, notes, model references, prompts, sampler settings, safe node parameters, and a reproducibility fingerprint. Recipes append to the current canvas without replacing it.
- **Recipe Detail Workspace (配方详情)**: Added Overview, Parameter Notebook, Gallery, and Versions tabs. Long values can be expanded or copied, model requirements are shown as separate cards, and the overview avoids exposing local model paths as display names.
- **Parameter Notebooks (参数笔记本)**: Create edited parameter notes, capture a fresh note from a matching live canvas, switch between saved notes with clear feedback, delete individual notes, and apply one note back to a workflow only after the complete recipe skeleton matches. Volatile values such as seeds are intentionally ignored.
- **Node Assistant Recipe Presets (节点助手配方预设)**: The Parameter Presets tab reads same-type node values saved by Workflow Recipes. Select a node such as KSampler and apply sampler, scheduler, steps, CFG, denoise, and other safe values in one click without replacing the rest of the workflow.
- **Model Identity and Local Matching (模型身份与本地匹配)**: Recipe model cards retain hashes, size, model category, available Civitai origin names/links, and previews. Imported recipes can match another author's model references to local files without trusting their folder paths.
- **Recipe Gallery and Parameter Comparison (配方图库与参数对比)**: Find historical outputs with the same node structure, inspect embedded generation data, compare differing parameters, and promote an output image to the recipe cover.
- **Version History (版本历史)**: Recipe updates retain bounded local history. Compare meaningful changes, restore an older version safely, and preserve the current version before restoration.
- **Portable Recipe Packages (便携配方包)**: Import or export recipes with optional preview snapshots and history. Model identity hashes can be removed for privacy. Preview snapshots contain presentation images only and never include model files.
- **Conservative Prompt Roles (保守的正负提示词识别)**: Native supported prompt paths are classified without title guessing. Unknown third-party text nodes remain unclassified until the user labels them as positive, negative, shared, or ignored.
### ✨ Interface and Interaction
- Renamed the combined Notes and Recipes area to **Creative Workspace / 创作工作台**, with separate Prompt Notes and Workflow Recipes sections.
- Recipe models, LoRAs, prompt text, common parameters, and generic node controls use dedicated responsive layouts with copy and full-content expansion where needed.
- Returning from a model detail page restores the previous recipe position instead of jumping back to the top.
- The main output gallery refreshes when opened, avoiding continuous background polling.
- Beta and data-protection guidance is available in the Recipe list, the Node Assistant Parameter Presets tab, Settings Help, and README in both Chinese and English.
### 🐛 Reliability Fixes
- Fixed a cross-panel state leak where opening a model from a Recipe Overview could overlap Node Assistant, then leave the next Workflow Recipe visit with an empty list. Returning normally, switching directly to another main panel, reopening Workspace, and closing Workspace mid-return now all restore one coherent surface.
- Fixed blank Workspace/Parameter Notebook surfaces and restored the plugin trigger when frontend module errors prevented registration.
- Fixed parameter application against current ComfyUI widget callbacks and added transactional rollback when a node update fails.
- Fixed positive/negative prompt duplication and legacy role recovery while keeping ambiguous third-party prompt nodes visibly unknown.
- Fixed panel closing and canvas handoff behavior, docked notebook confirmation controls, recipe scroll restoration, long model status text, and narrow parameter layouts.
- Added backend validation and regression coverage for recipe round-tripping, linked inputs, parameter-note deletion, model matching, and prompt-role metadata.
## v1.5.1 (The Workflow Exchange & Deep Scan Update)
### 🚀 Major Features
- **Lossless Workflow Exchange (无损工作流交互)**: Added dedicated Import/Export buttons to the bottom-left of the plugin UI. This feature allows users to directly extract, share, and import pure workflow data without relying on original PNG images, completely bypassing the metadata-stripping compression used by social platforms (like WeChat or Discord) that causes workflow loss.
- **Force Overwrite Configs (强制覆盖已有配置)**: Added a new "Force Overwrite" toggle in the Scan Wizard. When enabled, the scanner will deliberately ignore existing local `.info` files and force a fresh SHA256 hash calculation and a complete metadata pull from Civitai. This is a lifesaver for repairing incorrectly matched or corrupted model profiles.
- **Model Card Settings (模型卡片设置)**: The Settings Hub now opens one focused panel for video-cover playback (`Always play` or recommended `Play on hover`) and card image quality (`Optimized thumbnail` or `Original cover`). Existing autoplay preferences remain compatible, while image-quality choice is intentionally simple and local to the browser.
### 🐛 Bug Fixes
- **Base Model Hash Misattribution**: Fixed a critical bug in the fast scanner where it would aggressively fuzzy-match any 64-character hex string in the metadata. This previously caused LoRAs (trained via Kohya) to inherit their Base Model's hash (`ss_sd_model_hash`), resulting in totally incorrect Civitai mappings. The scanner now strictly adheres to the official `modelspec.hash.sha256/blake3` standard.
- **Lossless Cover Reset**: Reset now restores the persistent `.civitai_bak.*` copy first, falls back to a bare original cover for non-Civitai models, and preserves the active preview with a visible warning when no recoverable source exists.
- **Safe Sidecar Lifecycle**: Deleting a model cleans recognized sidecars, while renaming migrates them—including the Civitai backup—to the new stem. Same-stem model files with different model extensions are never treated as sidecar garbage.
- **Bounded Sidecar I/O**: Repeated extension literals in the API and scanner were replaced with fixed tuples and exact-path checks. Rename, delete, and reset remain constant-bounded and never scan an entire model directory.
- **Reset/Rename Extension Integrity**: Fixed a reused loop variable that could change a model's extension when cover reset and physical rename were requested together.
- **Signature-Aware Metadata Cache**: Unchanged model metadata and embedded safetensors hashes are now served from bounded caches keyed by physical file signatures. Sidecar or model changes invalidate entries automatically, while defensive copies prevent cache pollution.
- **Stable Cover Caching**: Removed per-refresh random cover URLs and switched to nanosecond file-version tokens, allowing the browser to reuse unchanged images and videos while still updating changed covers immediately.
- **Responsive Large Libraries**: Folder/model disk work now runs outside the aiohttp event loop, current-folder discovery uses one `os.scandir()` inventory, stale requests are aborted, and cards render in animation-frame chunks with lazy media activation.
- **Batched Model Doctor Resolution**: Provenance-rich workflows skip a redundant full hash-cache walk, then resolve missing nodes through a type-grouped batch endpoint so each required category is walked once per repair pass. Legacy workflows still refresh the filename cache when needed. The original hash/size/type/ambiguity rules remain unchanged, with automatic fallback to the single-item endpoint.
- **Direct Preview Fast Path**: Visual replacement resolves contained relative model paths directly and performs a full library walk only for unresolved basename fallbacks.
- **Balanced Cover Memory Use**: Static grid covers default to cached 512px WebP derivatives while detail views retain the original. Derived files are stored in ComfyUI's temporary area, capped at 256 MiB, and never alter source covers. Closing the browser releases active media immediately and releases the warm card DOM after 90 seconds of inactivity.
## v1.5.0 (The Workflow & UI Evolution Update)
### 🚀 Major Features
@@ -238,7 +401,11 @@
- **Offline Base Model Inference**: Added a zero-API local inference engine! For models downloaded purely from HuggingFace (or private unreleased models) that return a 404 on Civitai, the scanner no longer gives up. It now forcibly parses the .safetensors structure and uses **Tensor Fingerprinting** (e.g., detecting double_blocks.0.img_attn for Flux) to accurately deduce the underlying base architecture with 100% precision.
- **Universal UI Integration**: Successfully inferred offline models are dynamically assigned a virtual .info payload (ID: -1). This instantly grants them full VIP access to the frontend ecosystem—they seamlessly appear in the Cross-Folder Radar, interact perfectly with the bilingual Notebook, and support one-click Auto-Inject loaders, all completely completely offline!
# 📈 Anomalous Model Browser Changelog
## v2.0.0 (The Workflow & UI Evolution Update)
### 🚀 Major Features
+5
View File
@@ -0,0 +1,5 @@
# Claude 工作入口
本仓库的开发与维护规范以 AGENTS.md 为唯一正文,第 0 节是每次提交都必须做到的要求;这里只做入口,不另写规则。
@AGENTS.md
+2 -2
View File
@@ -1,6 +1,6 @@
# Gemini 工作入口
在本插件中修改任何文件前,先阅读并遵守根目录的 [AGENTS.md](AGENTS.md)。它是本仓库开发与维护规范的唯一正文,适用于 UX 调整、修复和重构。
在本插件中修改任何文件前,先阅读并遵守根目录的 [AGENTS.md](AGENTS.md)。它是本仓库开发与维护规范的唯一正文,适用于 UX 调整、修复和重构;第 0 节是每次提交都必须做到的要求。
随后阅读 [ARCHITECTURE.md](ARCHITECTURE.md),按其中的阅读地图加载与当前任务有关的专题。
@@ -8,4 +8,4 @@
如果当前工具没有加载本文件,任务发起者应明确要求 Gemini 阅读 `GEMINI.md` 和 `AGENTS.md`。文件的存在不代表工具已实际读取,更不代表检查已自动执行。
前端设计任务另读 [前端设计与协作方案](docs/plans/frontend-design-brief.md);具体功能计划按该文链接进入,不使用历史视觉建议作为当前任务单。
前端设计任务按 AGENTS.md 第 4 节和 [前端架构](docs/architecture/frontend.md) 进行,不使用历史视觉建议作为当前任务单。
+144 -135
View File
@@ -2,20 +2,24 @@
# 🚀 Anomalous Model Browser
**A Comprehensive Creative Workspace & Model Manager for ComfyUI**
*零依赖 C 站元数据抓取 · 智能模型医生 · 可视化节点助手 · 工作流配方与参数笔记*
**A model browser and creative workspace for ComfyUI**
*模型库 · 出图图库 · 工作流配方 · 搭配 · 提示词 · 角色配音 · 模型检查 · 操作记录*
<br/>
[![ComfyUI Manager](https://img.shields.io/badge/ComfyUI-Manager-green?style=for-the-badge&logo=comfyui)](https://github.com/ltdrdata/ComfyUI-Manager)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](LICENSE)
[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.57_Beta-blue?style=for-the-badge)](CHANGELOG.md)
[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.58.2_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)
<br/>
[**English**](#-english) | [**中文说明**](#-中文)
[**English**](#english) | [**中文说明**](#中文)
<br/>
<img src="docs/images/models.webp" width="900" alt="Models with their Civitai covers, base model and real names">
</div>
@@ -23,23 +27,68 @@
<h2 id="english">🇬🇧 English</h2>
> **Anomalous Model Browser** is a comprehensive creative workspace and model management suite for ComfyUI. It integrates hash-based metadata indexing, workflow healing (Model Doctor), canvas visual model swapping (Node Assistant), a unified Material Library with polymorphic canvas drag-and-drop, and immutable Workflow Recipes.
> **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/)
### 🌟 Core Capabilities
### 👀 See it in action
| 🚀 Primary View | 💡 Description |
*The pictures show the Chinese interface; English is one switch away in Settings.*
**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/scan.webp" width="520" alt="Scan results: each file matched on Civitai"></p>
**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/model-check.webp" width="760" alt="Model Check puts back three missing models with one press"></p>
**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/gallery.webp" width="760" alt="Gallery search, then drag an image onto the canvas"></p>
**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/combo.webp" width="760" alt="A combo card dragged onto the canvas becomes wired nodes"></p>
**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/prompt-studio.webp" width="760" alt="Prompt Studio editing a prompt node as tags"></p>
**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/activity.webp" width="560" alt="Activity log with an undo button"></p>
**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/voices.webp" width="760" alt="Character voices page"></p>
### 🌟 What it does
Every page has an icon on the rail at the left of the window.
| Page | What you do there |
| :--- | :--- |
| **Model Browser (📦 Models)** | Browse models categorized by folder (checkpoints, unet, loras). Displays Civitai metadata, triggers, architectures, and high-res covers. Supports visual swapping, renaming, and notes. |
| **History Gallery (🖼️ Gallery)** | Native viewer for the ComfyUI `output` directory. Features multi-dimensional search (prompt, model, seed, hash) with removable filter blocks, mouse-wheel zoom, and drag-to-canvas workflow reconstruction. |
| **Workflow Recipes (🪡 Workflows)** | Save full workflows or partial subgraphs. Displays live model readiness status (e.g. all models ready). Open recipes in a new canvas or drag cards directly onto the canvas to load. |
| **Toolbox & Bottom Dock (🧰)** | Lower-left navigation bar featuring the Toolbox as the first icon. Directly hosts 4 frequent tools (Scan, Doctor, Assistant, Material Library) plus Settings. Clicking the Toolbox opens a panel containing 5 secondary utilities (Workflow Transfer, Prompt Studio, Prompt Translator, Model Source Hub, Prompt Notes). |
| **Workflow Repair (Model Doctor 🩺)** | Automatically detects broken nodes in external workflows and reconnects them to local models by comparing SHA-256 hashes and file sizes. |
| **Node Assistant & Presets (🤖)** | Canvas-linked sidebar. Select any node on the canvas to inspect covers/triggers, visually swap models, or apply recipe parameters transactionally. |
| **Material Library & Polymorphic Drag (✨)** | Save curated bundles from output PNGs (workflow, provenance, node parameters). Drop onto canvas to open workflow, drop prompts to auto-create native CLIP nodes, or drop onto existing nodes to inject parameters. |
| **🏠 Home** | Pick what you want to do: a card per task, first steps (scan your model folders, a guided tour of the interface) and your latest activity. |
| **🕘 Activity** | Everything the plugin changed, by day: on the canvas (which node settings changed from what to what, nodes added or removed, workflows opened) and in files (models, covers, recipes, materials, notes, images, audio). **Find on canvas** jumps to the node; a canvas change can be undone with **Undo** until something changes it again, the rest is recorded only. Your own edits are not listed. |
| **📦 Models** | Your models with covers, trigger words and base model. Chips at the top switch between types (Checkpoint, LoRA, VAE…) and list a whole type, subfolders included; the folder list narrows it to one folder. **+** adds a loader node to the canvas; edit a model's name, notes and cover, or scan just that model. |
| **🖼️ Gallery** | Your ComfyUI `output` folder. Search by prompt, model, LoRA, seed, file name or model hash; open an image to see the parameters it was made with, or drag it onto the canvas to get its workflow back. **☆** keeps a good one as a whole workflow, a combo (model, LoRAs, prompt) or just its prompts. |
| **🪡 Workflows** | Workflow recipes: save a whole workflow or a part of one together with its models, cover, notes and parameters. Each card shows whether the models are on this computer; drag it onto the canvas to load it. Versions can be compared and restored. |
| **🧩 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. |
| **🎙️ 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.
* **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.
* **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**.
### 📦 Quick Installation
@@ -49,87 +98,93 @@
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.
Open the browser via **Ctrl + Shift + M**, the floating canvas orb (**📦**), or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** lets you customize the shortcut, configure the UI language, and select an entry mode (floating button, action-bar button, or Extensions menu only).
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).
<br/>
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.
<details>
<summary><b>📖 Click to Expand: Step-by-Step Operating Guide</b></summary>
<br/>
#### 1. Top Navigation Views (三大顶栏视图)
The header provides 3 primary workspaces:
* **Models (📦)**: Main model catalog. The left panel shows folder trees (`checkpoints`, `unet`, `loras`), while the center grid displays model cards with previews, trigger tags, and metadata.
* **Gallery (🖼️)**: Historical output browser. The top search bar filters outputs by prompt, model name, seed, filename, or model hash; each search keyword forms an independent block with a `×` button for instant removal. Dragging an image onto the canvas reconstructs its embedded workflow.
* **Workflows (🪡)**: Workflow Recipe studio. Filter by `[All]`, `[Full Workflows]`, or `[Subgraphs]`. Click `Save Current Workflow` to save the active canvas, or drag any recipe card directly onto the canvas to load it.
#### 2. Bottom Navigation & Toolbox Catalog (🧰 第一个图标:实用工具箱)
* **Dock Layout**: The lower-left navigation bar hosts 6 permanent buttons in a fixed layout:
1. **Toolbox (🧰)**: First anchor on the left. Opens the Toolbox panel for secondary tools.
2. **Scan Wizard (🔄)**: Direct shortcut to launch model library scanning.
3. **Model Doctor (🩺)**: Direct shortcut to diagnose and repair missing workflow nodes.
4. **Node Assistant (🤖)**: Direct shortcut to inspect canvas node models and inject parameter presets.
5. **Material Library (✨)**: Direct shortcut to manage curated bundles and drag assets to the canvas.
6. **Settings (⚙️)**: Fixed anchor on the far right for global preferences.
* **Toolbox Catalog**: Clicking the first icon (**🧰 实用工具箱**) opens a dedicated popup panel containing 5 secondary utilities:
1. **Workflow Transfer Center (⇄ 导入导出)**: Lossless AMB format workflow share code import and export. Allows importing workflows from text codes or exporting the current canvas with embedded hashes.
2. **Prompt Studio (🎛️ 提示词工坊)**: Modular prompt mixer deck. Extract prompt cards from history or materials, reorder segments, and assemble positive/negative prompts.
3. **Prompt Translator (🌐 翻译助手)**: Built-in bilingual prompt translation between Chinese and English without leaving the interface.
4. **Model Source Hub (🔗 模型来源)**: Dual-scope (workflow and library) download URL inspector. Navigates to Civitai, HuggingFace, Liblib, or ModelScope, and generates non-intrusive canvas Note nodes.
5. **Prompt Notes (📑 提示词笔记)**: Lightweight notebook for drafting, editing, and managing reusable prompt snippets saved locally in the user directory (`workflows/anomalous_notebooks`).
#### 3. Scan Wizard (🔄 扫描向导)
* **Location**: Direct shortcut on the bottom dock (2nd icon).
* **Operation**: Scan configured model directories to compute file hashes, download Civitai covers, tags, and architectures, and build the local offline database.
#### 4. Model Doctor (🩺 模型医生)
* **Location**: Direct shortcut on the bottom dock (3rd icon).
* **Operation**: When loading an external workflow with missing red nodes, Model Doctor inspects embedded provenance hashes and file sizes to reconnect local matches automatically. The "View Hash" button opens a side-by-side SHA256 comparison modal.
#### 5. Node Assistant & Parameter Presets (🤖 节点助手)
* **Location**: Direct shortcut on the bottom dock (4th icon).
* **Actions**: Select a model node on the canvas to view high-res previews and trigger words, or swap models visually. Switch to **Parameter Presets** to inject recipe-saved node values (e.g., KSampler steps, CFG, denoise) into matching canvas nodes with safe seed preservation.
#### 6. Material Library & Polymorphic Canvas Drag (✨ 素材库与多态拖拽)
* **Location**: Direct shortcut on the bottom dock (5th icon).
* **Capture**: In Gallery or Workflows, open an image's parameter inspector to save its image, workflow, and node blocks as a curated material bundle (`workflows/anomalous_materials`).
* **Polymorphic Canvas Drag**:
- Dragging a workflow material to an empty canvas area opens the full workflow.
- Dragging a prompt-only material to an empty canvas auto-creates native `CLIPTextEncode` nodes with distinct negative (`#532323`) and positive (`#235327`) styling.
- Dragging materials onto existing canvas nodes injects matching parameter values directly.
#### 7. Global Settings (⚙️ 设置)
* **Location**: Direct shortcut on the far right of the lower-left navigation bar (6th icon).
* **Options**: Interface language, font scale, thumbnail rendering, video cover hover behavior, and model folder blacklist.
> [!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.
> [!WARNING]
> **Beta Data Protection:** Workflow Recipes, Material Library, and Parameter Presets are currently in active preview. Please back up `workflows/anomalous_recipes`, `workflows/anomalous_materials`, and `workflows/anomalous_parameters` inside your ComfyUI user directory before updating.
</details>
> **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.
---
<h2 id="中文">🇨🇳 中文说明</h2>
> **Anomalous Model Browser** 是为 ComfyUI 设计的综合创作工作台与模型管理套件。系统集成了基于文件哈希的元数据提取、工作流缺失模型自愈(模型医生)、画布节点可视化换模与 LoRA 插入(节点助手)、支持多态画布拖拽的统一素材库,以及工作流配方体系。
> **Anomalous Model Browser** 在一个窗口里帮你打理 ComfyUI 周边的杂事:
> - 带 C 站封面和触发词浏览模型;
> - 翻看以前的出图;
> - 保存调好的工作流,存下常用的模型搭配和提示词;
> - 别人的工作流缺模型时自动找回;
> - 配合配套节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 给角色配音。
>
> 插件改动过什么,都能在“操作记录”里查到。
### 🎬 视频演示与教程
* 📺 **哔哩哔哩 (Bilibili)**:[在 B 站观看快速上手与使用演示](https://www.bilibili.com/video/BV1a1bv68EuA/)
* 📺 **YouTube**:[在 YouTube 观看视频演示](https://youtu.be/hAvsj7uiaCw)
### 🌟 核心特性速览
### 👀 看图上手
| 🚀 核心视图与工具 | 💡 详细说明 |
**1. 扫描一次,以后看封面挑模型。** 点 **扫描**,每个模型都按文件哈希去 C 站认一遍。就算文件叫 `下载 (3).safetensors`,也能显示出它真正的名字、封面、触发词和底模。
<p align="center"><img src="docs/images/scan.webp" width="520" alt="扫描结果:每个文件都在 C 站认出来了"></p>
**2. 打开别人的工作流,不用自己找模型。** 缺模型时画布上方会提示。你电脑里其实有、只是改了名或换了文件夹的,点一下就换上,红框随之消失。
<p align="center"><img src="docs/images/model-check.webp" width="760" alt="模型检查一键换上 3 个缺失的模型"></p>
**3. 找到以前的图,拿回当时的工作流。** 在图库里按提示词、模型或 LoRA 搜,再把图拖到画布上。
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="图库搜索后把图拖到画布"></p>
**4. 存下“模型 + LoRA + 提示词”的搭配,一拖就用。** 把搭配卡片拖到画布空白处,就放出一组连好线的节点,画布上原有的东西不动。
<p align="center"><img src="docs/images/combo.webp" width="760" alt="搭配卡片拖到画布变成连好线的节点"></p>
**5. 像搭积木一样改提示词。** 选中提示词节点,提示词工坊把它拆成词块:点一下调权重,拖动换位置,输入就加(中文会自动译成英文)。
<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 角色,每种情绪一段参考音频,在配音页写台词直接生成。
<p align="center"><img src="docs/images/voices.webp" width="760" alt="角色语音页面"></p>
### 🌟 能做什么
窗口左侧的图标栏,每个页面一个图标。
| 页面 | 在这里做什么 |
| :--- | :--- |
| **模型浏览 (📦 模型)** | 本地模型管理网格。左侧树状目录分类(checkpoints、unet、loras),卡片展示 C 站元数据、触发词、基础架构与高清封面,支持看图换模与自定义备注。 |
| **出图图库 (🖼️ 图库)** | 原生读取本地 `output` 文件夹,支持滚轮缩放与安全删除;顶部搜索栏支持按提示词、模型名、LoRA、seed、文件名或模型哈希多维检索,生成可点击独立移除的搜索标签块;拖拽图片原地还原工作流。 |
| **工作流配方 (🪡 工作流)** | 工作流配方工坊。支持保存完整工作流或局部子图,实时显示模型就绪状态(如“全部模型就绪”);支持一键在新画布打开或直接拖拽卡片到画布空白处载入。 |
| **实用工具箱与底栏 (🧰)** | 侧边栏左下角第一个图标为工具箱,底栏直达 4 大常用工具(扫描向导、模型医生、节点助手、素材库)与全局设置;点击工具箱弹出浮窗面板,提供 5 大扩展工具(导入导出、提示词工坊、翻译助手、模型来源、提示词笔记)。 |
| **节点智能修复 (模型医生 🩺)** | 导入他人工作流发生节点爆红时,自动比对本地模型 SHA256 哈希与字节大小,一键替换为有效路径,支持哈希透视比对。 |
| **节点助手与参数预设 (🤖)** | 画布选中节点即可可视化选图换模型、向兼容链路插入 LoRA,或一键应用工作流配方中沉淀的节点参数(自动跳过易变种子)。 |
| **统一素材库与多态拖拽 (✨)** | 原生 PNG 资产一键打包保存(完整工作流、模型血缘与节点参数);多态拖拽:拖至空白画布还原工作流或生成原生红绿提示词节点,拖至已有节点智能注入参数。 |
| **🏠 主页** | 想做什么就点哪张卡片。还有上手第一步(扫描模型文件夹、界面导览)和最近的操作记录。 |
| **🕘 记录** | 按天列出插件做过的每一处改动。<br>画布上:哪个节点的设置从什么改成了什么、加了或删了哪些节点、打开了哪个工作流。<br>文件上:模型、封面、配方、素材、笔记、图片、音频。<br>**在画布上找到** 可以直接跳到那个节点;画布上的改动在没被再改过之前可以点 **撤销**,其余只记录。你自己在画布上的修改不会记进来。 |
| **📦 模型** | 模型带封面、触发词和底模信息。<br>顶部标签切换类型(Checkpoint、LoRA、VAE…),连同子文件夹一起列出;左侧文件夹列表可以只看某个文件夹。<br>**+** 一键在画布上创建加载节点;也可以改名、写备注、换封面,或只扫描这一个模型。 |
| **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹。<br>可以按提示词、模型、LoRA、seed、文件名或模型哈希搜索。<br>点开图片能看到生成参数;拖到画布上可以还原当时的工作流。<br>觉得好就点 **☆**:存成整个工作流、搭配(模型 + LoRA + 提示词),或者只存提示词。 |
| **🪡 工作流** | 工作流配方:把完整工作流或其中一段,连同模型、封面、备注和参数一起存下来。<br>卡片会显示这台电脑上模型是否齐全;拖到画布上即可载入。<br>可以比较、恢复历史版本。 |
| **🧩 搭配** | 把常用的主模型、几个 LoRA 和一段提示词存成一个搭配。<br>按 **放到画布**(或把卡片拖到画布上),就放出一组连好线的节点;画布上原有的东西不动。 |
| **✍️ 提示词** | 在画布旁边打开提示词工坊。选中一个提示词节点,它的提示词框就以词块的样子出现在工坊里,直接改:点一下调权重,双击改字,拖动换位置,输入就加(中文、日文等会自动译成英文),还能在每个词下面显示它的中文、日文、韩文等语言的意思。<br>点词卡(几张常用的,加上你存的)加进去,或者直接拖到画布的提示词框里(正面绿框、负面红框),拖到空白处就新建一个提示词节点。没选中提示词节点时写的是草稿。 |
| **🎙️ 角色语音 / 🎧 音频库** | 装了 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后可用:<br>导入 GPT-SoVITS 角色,给每种情绪选参考音频,修正读音;切到“配音”写台词并直接生成。<br>生成的音频都在音频库里。没装时,页面会说明怎么安装,其他功能不受影响。 |
图标栏上的工具:
* **扫描**:读取模型文件夹,按文件哈希从 C 站获取封面、触发词和底模。不需要额外安装 Python 包。
* **模型检查**(原“模型医生”):打开工作流时自动检查,缺模型就在画布上方提示;本地改过名、换过文件夹的同一个文件(按哈希和文件大小认)一键换上,其余的在检查页里自己挑或去 Civitai 找。
* **当前节点**(原“节点助手”):选中画布上的节点,查看它用的模型。可以看图换模型、插入 LoRA;存过的同类节点参数列在一起,每条写明会改哪几项,一键套用;存下的参数也在这里删除。
* **工具回到各自的页面**:工作流分享码(导入导出)在“工作流”页,模型来源(每个模型去哪下载)在“模型”页和模型检查里,翻译在“当前节点”的提示词框上。
* **设置**(最下面):语言、字号、缩略图、视频封面、显示哪些模型文件夹,以及提交反馈。主页和设置里的 **报告问题** 会打开一个已经填好版本和显卡信息的 GitHub 页面。
**不占你的资源**:
- 插件本身不加载任何模型,不和生图抢显存。
- 浏览时显示的是小缩略图,不加载原图;关闭窗口一分半后,图片也会释放掉。
- Anomalous_TTS 的语音模型在 ComfyUI 生图需要显存时会自动让出,点 **卸载模型** 时全部释放。
### 📦 快速安装
@@ -138,68 +193,22 @@ The header provides 3 primary workspaces:
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
2. 重启 ComfyUI 即可使用。(*也可以直接在 **ComfyUI Manager** 搜索 `Anomalous Model Browser` 点击安装*)
2. 重启 ComfyUI 即可使用。(*也可以直接在 **ComfyUI Manager** 搜索 `Anomalous Model Browser` 安装*)
3. 想用角色配音的话,用同样的方法再装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)。不装也不影响其他功能。
4. **更新插件**:
- 点窗口右上角的 **!**,可以看到当前版本;
- 点 **检查更新 / 切换版本** 打开版本面板,可以更新到最新版、回退到以前的版本,或回到最新版;
- 换完版本后重启 ComfyUI。插件不会自己联网检查更新。
可以按 **Ctrl + Shift + M**、点击画布上的悬浮 **📦**,或从 ComfyUI 顶部菜单 **扩展 → Anomalous Model Browser** 打开。**ComfyUI 设置 → Anomalous Model Browser → 界面** 会显示当前快捷键,并可直接打开 ComfyUI 原生录入窗口修改,因此冲突与保留按键检查仍然只有一套。该页面还可以让插件语言跟随 ComfyUI 或单独固定为中文/English,并严格三选一显示悬浮入口、运行按钮旁的原生顶部入口或仅使用扩展菜单。只有选择悬浮入口时才显示大小和样式选项。顶部栏重绘不会擅自改变所选模式,扩展菜单命令则始终保留,便于恢复设置。
打开方式有三种:按 **Shift + B**(再按一次关闭)、点击画布上的悬浮按钮,或者从 ComfyUI 菜单 **扩展 → Anomalous Model Browser** 打开。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 里,可以修改快捷键、插件语言和打开方式(悬浮按钮、运行按钮旁的按钮,或只用扩展菜单)。
<br/>
第一次打开时会停在 **主页**:点 **扫描模型文件夹** 补全封面和触发词;点 **界面导览** 会逐一指给你看窗口的各个部分。
<details>
<summary><b>📖 点击展开:标准操作指南(图文步骤)</b></summary>
<br/>
#### 1. 顶部主工作区导航 (三大顶栏视图)
插件顶部居中提供 3 个核心工作区入口:
* **模型 (📦)**:主模型浏览器。左侧为模型文件夹分类树(`checkpoints`、`unet`、`loras` 等),中间展示模型卡片网格、封面图预览与触发词。
* **图库 (🖼️)**:出图历史浏览器。原生读取本地 `output` 目录。顶部搜索栏支持输入提示词、模型名、LoRA、seed、文件名或模型哈希,回车即可生成独立的搜索标签块,点击标签块上的 `×` 可快速移除该过滤项;直接将图片拖拽至 ComfyUI 画布可原地恢复内嵌工作流。
* **工作流 (🪡)**:工作流配方工坊。提供 `[全部]`、`[完整工作流]`、`[局部子图]` 子过滤器与标签筛选;点击右上角 `保存当前工作流` 即可将当前画布打包为配方;直接按住配方卡片拖拽到 ComfyUI 画布空白处释放即可立即载入。
#### 2. 底部导航与实用工具箱 (🧰 第一个图标:实用工具箱)
* **布局结构**:侧边栏左下角常驻 6 个固定图标按钮:
1. **实用工具箱 (🧰)**:左侧首个图标,点击展开工具箱浮窗面板。
2. **扫描向导 (🔄)**:直达本地模型扫描与哈希/元数据建立。
3. **模型医生 (🩺)**:直达工作流爆红诊断与缺失模型自动自愈。
4. **节点助手 (🤖)**:直达画布选中节点模型信息查看、可视化换模与参数预设注入。
5. **素材库 (✨)**:直达素材资产管理与多态画布拖拽。
6. **全局设置 (⚙️)**:最右侧齿轮图标,直达全局偏好设置。
* **实用工具箱浮窗**:点击首个图标(**🧰 实用工具箱**)打开工具箱窗口,集中收纳了未在底栏常驻的 5 大扩展工具:
1. **导入导出 (⇄)**:无损 AMB 格式工作流分享码导入与导出,支持从文本分享码原地解析还原画布,或将当前画布打包生成带校验哈希的分享码。
2. **提示词工坊 (🎛️)**:模块化提示词积木组装台,支持从出图历史或素材中抽取词卡并分块混音、正负词排序与组合。
3. **翻译助手 (🌐)**:内置中英双向提示词互译工具,无需离开 ComfyUI 即可完成提示词中英翻译。
4. **模型来源 (🔗)**:检视当前工作流或模型库中模型的在线下载来源(Civitai、HuggingFace、Liblib、ModelScope),支持一键在画布生成包含模型来源信息的 Note 说明节点。
5. **提示词笔记 (📑)**:保存在 ComfyUI 用户目录(`workflows/anomalous_notebooks`)下的轻量提示词草稿本,用于随时记录、整理与复用常用提示词片段。
#### 3. 扫描向导 (🔄 扫描向导)
* **入口位置**:底部快捷栏第 2 个图标。
* **操作步骤**:首次使用或添加新模型后打开,选择扫描范围(全部路径、仅缺失元数据或自定义目录)并执行,系统将自动建立本地模型库并拉取 C 站封面、标签与架构信息。
#### 4. 拯救爆红 (🩺 模型医生)
* **入口位置**:底部快捷栏第 3 个图标。
* **操作步骤**:载入他人工作流或图片出现红框缺失报错时,点击模型医生即可自动比对本地模型哈希与字节大小,一键批量映射为本地正确路径。卡片上的“查看哈希”可展开内嵌指纹与磁盘文件的 SHA256 逐项比对面板。
#### 5. 选中交互与预设 (🤖 节点助手)
* **入口位置**:底部快捷栏第 4 个图标。
* **动作功能**:在画布选中模型节点后,可在“动作”页查看高清预览图与触发词,支持看图一键替换模型或在兼容链路前后插入 LoRA。
* **参数预设**:切换至“参数预设”页,可读取工作流配方中同类型节点的保存参数并一键注入画布(自动保留种子等易变数值)。
#### 6. 统一素材库与多态画布拖拽 (✨ 素材库与多态拖拽)
* **入口位置**:底部快捷栏第 5 个图标。
* **保存素材**:在出图图库或配方详情中打开任意生成 PNG 的参数面板,可一键将图片、完整工作流、模型依赖与可复用节点参数打包收藏。
* **多态画布拖拽**:
- 将工作流素材直接拖拽至空白画布,即可原地恢复并打开完整工作流。
- 将纯提示词素材拖拽至空白画布,会自动创建带有正负区分色(深红 `#532323` 为负向 / 墨绿 `#235327` 为正向)的原生 `CLIPTextEncode` 节点。
- 将素材拖拽至画布已有节点上时,会自动通过语义探针将匹配参数注入目标节点中。
* **本地存储**:素材存储于 ComfyUI 用户目录下的 `workflows/anomalous_materials`,支持原子写入。
#### 7. 全局设置面板 (⚙️ 设置)
* **入口位置**:侧边栏左下角最右侧第 6 个图标(**齿轮图标 ⚙️**)。
* **个性调节**:支持中英文界面切换、字体大小缩放、视频封面悬停/常开播放、缩略图优化与目录黑名单管理。
> [!NOTE]
> **关于网络:** 扫描模型要连 Civitai(C 站)查封面和触发词,提示词翻译默认用谷歌翻译,这两样在国内一般要开网络代理才能用。不开代理也行:扫描时选 **离线扫描**,只从模型文件推断底模,没有封面和触发词,以后能连上时再扫一次会自动补上。浏览模型、图库、工作流和搭配都不用联网。
> [!WARNING]
> **测试功能数据安全提醒:** 工作流配方、素材库与参数预设目前属于活跃测试阶段,更新插件前建议备份 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters` 文件夹。
</details>
> **备份自己的数据:** 工作流配方、搭配、存下的提示词和节点参数、参数预设存在 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_notebooks`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters` 文件夹。更新插件前顺手备份一份,更安心。
---
+2 -1
View File
@@ -5,7 +5,8 @@ from server import PromptServer
WEB_DIRECTORY = "./web"
# 注册 API 路由
setup_routes(PromptServer.instance.app)
if hasattr(PromptServer, "instance") and PromptServer.instance is not None:
setup_routes(PromptServer.instance.app)
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
+125 -99
View File
@@ -1,99 +1,125 @@
from . import (
folder_types, gallery_routes, materials, media_routes, model_catalog,
model_media, model_metadata, model_resolution, recipe_packages, recipes,
translation_routes,
)
from .scanner import *
from .config import *
from .notebooks import *
from .parameters import *
from aiohttp import web
@web.middleware
async def no_cache_extension_middleware(request, handler):
response = await handler(request)
if request.path.startswith('/extensions/Anomalous_Model_Browser'):
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
def setup_routes(app):
if hasattr(app, 'middlewares') and no_cache_extension_middleware not in app.middlewares:
app.middlewares.append(no_cache_extension_middleware)
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/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_post('/anomalous/scan', api_scan_folder)
app.router.add_get('/anomalous/scan_status', api_scan_status)
app.router.add_get('/anomalous/find_model', model_catalog.api_find_model)
app.router.add_get('/anomalous/config', api_get_config)
app.router.add_post('/anomalous/save_config', api_save_config)
app.router.add_post('/anomalous/delete_model', model_metadata.api_delete_model)
app.router.add_post('/anomalous/clean_civitai_info', api_clean_civitai_info)
app.router.add_get('/anomalous/compatible_models', model_catalog.api_compatible_models)
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
app.router.add_post('/anomalous/delete_notebook', api_delete_notebook)
# Parameter Routes
app.router.add_get('/anomalous/parameters', api_get_parameters)
app.router.add_get('/anomalous/parameters/by_node_type', api_get_parameters_by_type)
app.router.add_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)
# Recipe Routes
app.router.add_get('/anomalous/recipes', recipes.api_get_recipes)
app.router.add_get('/anomalous/recipe_full', recipes.api_get_recipe_full)
app.router.add_get('/anomalous/recipe_asset', recipes.api_get_recipe_asset)
app.router.add_get('/anomalous/recipe_gallery', recipes.api_get_recipe_gallery)
app.router.add_get('/anomalous/recipe_parameter_gallery', recipes.api_get_recipe_parameter_gallery)
app.router.add_get('/anomalous/recipe_gallery_compare', recipes.api_get_recipe_gallery_compare)
app.router.add_post('/anomalous/save_recipe', recipes.api_save_recipe)
app.router.add_post('/anomalous/update_recipe', recipes.api_update_recipe)
app.router.add_post('/anomalous/set_recipe_gallery_cover', recipes.api_set_recipe_gallery_cover)
app.router.add_post('/anomalous/delete_recipe', recipes.api_delete_recipe)
app.router.add_get('/anomalous/recipe_history', recipes.api_get_recipe_history)
app.router.add_get('/anomalous/recipe_version', recipes.api_get_recipe_version)
app.router.add_post('/anomalous/restore_recipe_version', recipes.api_restore_recipe_version)
app.router.add_post('/anomalous/refresh_recipe_identity', recipes.api_refresh_recipe_identity)
app.router.add_post('/anomalous/export_recipe_package', recipe_packages.api_export_recipe_package)
app.router.add_post('/anomalous/import_recipe_package_inspect', recipe_packages.api_import_recipe_package_inspect)
app.router.add_post('/anomalous/import_recipe_package_commit', recipe_packages.api_import_recipe_package_commit)
# Curated Material Library Routes
app.router.add_get('/anomalous/materials', materials.api_get_materials)
app.router.add_get('/anomalous/material_full', materials.api_get_material_full)
app.router.add_get('/anomalous/material_asset', materials.api_get_material_asset)
app.router.add_get('/anomalous/materials/by_node_type', materials.api_get_materials_by_node_type)
app.router.add_post('/anomalous/inspect_image_material', materials.api_inspect_image_material)
app.router.add_post('/anomalous/save_image_material', materials.api_save_image_material)
app.router.add_post('/anomalous/save_parameter_material', materials.api_save_parameter_material)
app.router.add_post('/anomalous/save_prompt_note_material', materials.api_save_prompt_note_material)
app.router.add_post('/anomalous/save_prompt_plan', materials.api_save_prompt_plan)
app.router.add_post('/anomalous/delete_material', materials.api_delete_material)
app.router.add_post('/anomalous/update_material', materials.api_update_material)
app.router.add_post('/anomalous/translate', translation_routes.api_translate)
app.router.add_get('/anomalous/base_models', model_catalog.api_base_models)
app.router.add_get('/anomalous/gallery_images', gallery_routes.api_get_gallery_images)
app.router.add_post('/anomalous/delete_gallery_image', gallery_routes.api_delete_gallery_image)
app.router.add_get('/anomalous/resolve_hash', model_resolution.api_resolve_hash)
app.router.add_post('/anomalous/resolve_hash_batch', model_resolution.api_resolve_hash_batch)
app.router.add_get('/anomalous/all_hashes', model_resolution.api_get_all_hashes)
app.router.add_post('/anomalous/scan_all', api_scan_all)
app.router.add_get('/anomalous/global_scan_status', api_global_scan_status)
app.router.add_get('/anomalous/scan_missing_models_status', api_scan_missing_models_status)
app.router.add_post('/anomalous/clear_cache', media_routes.api_clear_cache)
app.router.add_post('/anomalous/update_metadata', model_metadata.api_update_metadata)
app.router.add_post('/anomalous/set_custom_cover', model_media.api_set_custom_cover)
app.router.add_post('/anomalous/upload_custom_cover', model_media.api_upload_custom_cover)
app.router.add_get('/anomalous/model_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_post('/anomalous/scan_missing_models', api_scan_missing_models)
from . import (
activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog,
model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_report, scan_summary,
local_guard, translation_routes, version_manager,
)
from .scanner import *
from .config import *
from .notebooks import *
from .parameters import *
from aiohttp import web
EXTENSION_STATIC_PREFIX = '/extensions/Anomalous_Model_Browser/'
@web.middleware
async def no_cache_extension_middleware(request, handler):
"""Make browsers revalidate this plugin's JS/CSS so a normal refresh picks up updates.
`no-cache` still allows 304 responses via ETag/Last-Modified. This replaces
per-import `?v=` query strings, which load a second module instance whenever
two files import the same module with different URLs.
"""
response = await handler(request)
if request.path.startswith(EXTENSION_STATIC_PREFIX):
response.headers['Cache-Control'] = 'no-cache'
return response
def setup_routes(app):
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)
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)
app.router.add_post('/anomalous/scan', api_scan_folder)
app.router.add_get('/anomalous/scan_status', api_scan_status)
app.router.add_get('/anomalous/find_model', model_catalog.api_find_model)
app.router.add_get('/anomalous/config', api_get_config)
app.router.add_post('/anomalous/save_config', api_save_config)
app.router.add_post('/anomalous/delete_model', model_metadata.api_delete_model)
app.router.add_post('/anomalous/open_model_folder', model_folder.api_open_model_folder)
app.router.add_get('/anomalous/compatible_models', model_catalog.api_compatible_models)
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
app.router.add_post('/anomalous/delete_notebook', api_delete_notebook)
# Parameter Routes
app.router.add_get('/anomalous/parameters', api_get_parameters)
app.router.add_get('/anomalous/parameters/by_node_type', api_get_parameters_by_type)
app.router.add_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)
# Recipe Routes
app.router.add_get('/anomalous/recipes', recipes.api_get_recipes)
app.router.add_get('/anomalous/recipe_full', recipes.api_get_recipe_full)
app.router.add_get('/anomalous/recipe_asset', recipes.api_get_recipe_asset)
app.router.add_get('/anomalous/recipe_gallery', recipes.api_get_recipe_gallery)
app.router.add_get('/anomalous/recipe_parameter_gallery', recipes.api_get_recipe_parameter_gallery)
app.router.add_get('/anomalous/recipe_gallery_compare', recipes.api_get_recipe_gallery_compare)
app.router.add_post('/anomalous/save_recipe', recipes.api_save_recipe)
app.router.add_post('/anomalous/update_recipe', recipes.api_update_recipe)
app.router.add_post('/anomalous/set_recipe_gallery_cover', recipes.api_set_recipe_gallery_cover)
app.router.add_post('/anomalous/delete_recipe', recipes.api_delete_recipe)
app.router.add_get('/anomalous/recipe_history', recipes.api_get_recipe_history)
app.router.add_get('/anomalous/recipe_version', recipes.api_get_recipe_version)
app.router.add_post('/anomalous/restore_recipe_version', recipes.api_restore_recipe_version)
app.router.add_post('/anomalous/refresh_recipe_identity', recipes.api_refresh_recipe_identity)
app.router.add_post('/anomalous/export_recipe_package', recipe_packages.api_export_recipe_package)
app.router.add_post('/anomalous/import_recipe_package_inspect', recipe_packages.api_import_recipe_package_inspect)
app.router.add_post('/anomalous/import_recipe_package_commit', recipe_packages.api_import_recipe_package_commit)
# Curated Material Library Routes
app.router.add_get('/anomalous/materials', materials.api_get_materials)
app.router.add_get('/anomalous/material_full', materials.api_get_material_full)
app.router.add_get('/anomalous/material_asset', materials.api_get_material_asset)
app.router.add_get('/anomalous/materials/by_node_type', materials.api_get_materials_by_node_type)
app.router.add_post('/anomalous/inspect_image_material', materials.api_inspect_image_material)
app.router.add_post('/anomalous/save_image_material', materials.api_save_image_material)
app.router.add_post('/anomalous/save_prompt_plan', materials.api_save_prompt_plan)
app.router.add_post('/anomalous/save_node_material', node_material.api_save_node_material)
app.router.add_post('/anomalous/delete_material', materials.api_delete_material)
app.router.add_post('/anomalous/update_material', materials.api_update_material)
app.router.add_post('/anomalous/mark_material_moved', materials.api_mark_material_moved)
app.router.add_post('/anomalous/translate', translation_routes.api_translate)
app.router.add_get('/anomalous/base_models', model_catalog.api_base_models)
app.router.add_get('/anomalous/gallery_images', gallery_routes.api_get_gallery_images)
app.router.add_post('/anomalous/delete_gallery_image', gallery_routes.api_delete_gallery_image)
app.router.add_get('/anomalous/output_thumbnail', gallery_routes.api_output_thumbnail)
app.router.add_get('/anomalous/kept_images', kept_images.api_kept_images)
app.router.add_get('/anomalous/resolve_hash', model_resolution.api_resolve_hash)
app.router.add_post('/anomalous/resolve_hash_batch', model_resolution.api_resolve_hash_batch)
app.router.add_get('/anomalous/all_hashes', model_resolution.api_get_all_hashes)
app.router.add_post('/anomalous/scan_all', api_scan_all)
app.router.add_get('/anomalous/global_scan_status', api_global_scan_status)
app.router.add_post('/anomalous/clear_cache', media_routes.api_clear_cache)
app.router.add_post('/anomalous/update_metadata', model_metadata.api_update_metadata)
app.router.add_post('/anomalous/set_custom_cover', model_media.api_set_custom_cover)
app.router.add_post('/anomalous/upload_custom_cover', model_media.api_upload_custom_cover)
app.router.add_get('/anomalous/model_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/last_scan', scan_report.api_last_scan)
app.router.add_get('/anomalous/scan_model', scan_report.api_scan_model)
# Audio & Voice Studio Routes
app.router.add_get('/anomalous/audio_stream', audio_catalog.api_serve_audio)
app.router.add_get('/anomalous/audio_gallery', audio_catalog.api_get_audio_gallery)
app.router.add_post('/anomalous/delete_audio_gallery', audio_catalog.api_delete_audio_gallery)
app.router.add_get('/anomalous/audio_previews', audio_catalog.api_get_audio_previews)
app.router.add_post('/anomalous/save_audio_preview', audio_catalog.api_save_audio_preview)
version_manager.register_routes(app)
activity_log.register_routes(app)
+278
View File
@@ -0,0 +1,278 @@
"""Activity log: what Anomalous changed, newest first, kept in the ComfyUI user folder.
Two sources feed it. The middleware records this plugin's own write requests
(and Anomalous_TTS's) after they succeed: files such as models, covers, recipes,
materials, notes, presets, images and audio. The browser posts the canvas changes
it made (`POST /anomalous/activity`). Only the newest MAX_ENTRIES are kept and long
values are shortened, so the file stays small. Recording never fails a request:
the log is a convenience, not part of the action.
"""
import asyncio
import json
import logging
import os
import threading
import time
import uuid
from aiohttp import web
import folder_paths
from .path_utils import atomic_write_json
MAX_ENTRIES = 500
MAX_LOG_BYTES = 2 * 1024 * 1024 # long canvas entries could otherwise outgrow the JSON writer's limit
MAX_VALUE_CHARS = 400
DIFF_CONTEXT_CHARS = 60 # kept before the first difference of a long before/after pair
MAX_CHANGES = 60
# Write routes worth a line in the log → the action id the browser has words for. Scans are
# recorded when they end, with what they found (api/scan_report.py), not here.
ROUTE_ACTIONS = {
'/anomalous/delete_model': 'model_delete',
'/anomalous/update_metadata': 'model_edit',
'/anomalous/set_custom_cover': 'model_cover',
'/anomalous/upload_custom_cover': 'model_cover',
'/anomalous/save_recipe': 'recipe_save',
'/anomalous/update_recipe': 'recipe_edit',
'/anomalous/delete_recipe': 'recipe_delete',
'/anomalous/restore_recipe_version': 'recipe_restore',
'/anomalous/set_recipe_gallery_cover': 'recipe_cover',
'/anomalous/import_recipe_package_commit': 'recipe_import',
'/anomalous/save_image_material': 'material_save',
'/anomalous/save_prompt_plan': 'material_save',
'/anomalous/update_material': 'material_edit',
'/anomalous/delete_material': 'material_delete',
'/anomalous/save_notebook': 'note_save',
'/anomalous/delete_notebook': 'note_delete',
'/anomalous/save_parameter': 'preset_save',
'/anomalous/rename_parameter': 'preset_rename',
'/anomalous/delete_parameter': 'preset_delete',
'/anomalous/delete_gallery_image': 'image_delete',
'/anomalous/delete_audio_gallery': 'audio_delete',
'/anomalous/save_audio_preview': 'audio_save',
'/anomalous/clear_cache': 'cache_clear',
'/anomalous_tts/import/commit': 'voice_import',
'/anomalous_tts/settings': 'voice_settings',
}
# Request fields that name what was changed, in order of preference.
TARGET_FIELDS = ('name', 'character', 'filename', 'target_filename', 'recipe_filename',
'notebook_filename', 'new_name', 'path', 'file', 'target')
# What a handler attaches to its request for the log: request[ACTIVITY_DETAIL] = {fields, files}.
# web.RequestKey is newer than the aiohttp some ComfyUI installs have; a plain key works there.
ACTIVITY_DETAIL = web.RequestKey("activity_detail", dict) if hasattr(web, "RequestKey") else "activity_detail"
_lock = threading.RLock()
_entries = None
def log_path():
user_dir = folder_paths.get_user_directory() if hasattr(folder_paths, 'get_user_directory') else None
if not user_dir:
user_dir = os.path.join(folder_paths.base_path, 'user')
directory = os.path.join(user_dir, 'anomalous')
os.makedirs(directory, exist_ok=True)
return os.path.join(directory, 'activity_log.json')
def _load():
global _entries
if _entries is None:
try:
with open(log_path(), encoding='utf-8') as source:
data = json.load(source)
_entries = [entry for entry in data if isinstance(entry, dict)] if isinstance(data, list) else []
except (OSError, ValueError):
_entries = []
return _entries
def _encoded_size(entries):
return len(json.dumps(entries, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
def _trim(entries):
"""Drop the oldest entries until the list fits MAX_ENTRIES and MAX_LOG_BYTES."""
del entries[MAX_ENTRIES:]
while len(entries) > 1 and _encoded_size(entries) > MAX_LOG_BYTES:
del entries[max(1, len(entries) * 9 // 10):]
def _text(value):
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def _short(value):
text = _text(value)
return text if len(text) <= MAX_VALUE_CHARS else text[:MAX_VALUE_CHARS] + '…'
def _short_values(item, keys):
"""`keys` of `item` shortened. A long before/after pair is cut around its first difference
instead of at the start, so a change late in a long prompt still shows."""
out = {key: _short(item[key]) for key in keys if key in item and item[key] is not None}
if 'before' not in out or 'after' not in out:
return out
old, new = _text(item['before']), _text(item['after'])
if len(old) <= MAX_VALUE_CHARS and len(new) <= MAX_VALUE_CHARS:
return out
same = 0
while same < min(len(old), len(new)) and old[same] == new[same]:
same += 1
start = max(0, same - DIFF_CONTEXT_CHARS)
def cut(text):
return ('…' if start else '') + text[start:start + MAX_VALUE_CHARS] + ('…' if start + MAX_VALUE_CHARS < len(text) else '')
out['before'], out['after'] = cut(old), cut(new)
return out
def add_entry(source, action, target='', detail=None):
"""Append one entry (newest first) and save. Returns the entry."""
entry = {
'id': uuid.uuid4().hex[:12],
'time': time.time(),
'source': source,
'action': action,
'target': _short(target or ''),
}
if detail:
entry['detail'] = detail
with _lock:
entries = _load()
entries.insert(0, entry)
_trim(entries)
atomic_write_json(log_path(), entries)
return entry
def list_entries(limit=100, source=None):
with _lock:
entries = _load()
picked = [entry for entry in entries if not source or entry.get('source') == source]
return picked[:limit]
def clear():
global _entries
with _lock:
_entries = []
atomic_write_json(log_path(), [])
def _target_of(body):
if not isinstance(body, dict):
return ''
for field in TARGET_FIELDS:
value = body.get(field)
if isinstance(value, str) and value.strip():
return os.path.basename(value.strip().rstrip('/\\')) or value.strip()
return ''
def _failed(response):
"""True when a response reports failure, including the 200 + {"status": "error"} style."""
if response.status >= 300:
return True
if getattr(response, 'content_type', '') != 'application/json' or not getattr(response, 'body', None):
return False
try:
data = json.loads(response.body)
except (TypeError, ValueError):
return False
return isinstance(data, dict) and (data.get('success') is False or data.get('ok') is False
or data.get('status') in ('error', 'failed'))
async def _request_body(request):
"""The body the handler already read (aiohttp caches it); {} when it was not JSON or a form."""
try:
if request.content_type == 'application/json':
return await request.json()
if request.content_type in ('multipart/form-data', 'application/x-www-form-urlencoded'):
return dict(await request.post())
except (ValueError, UnicodeDecodeError, RuntimeError):
pass
return {}
def _clean_detail(detail):
"""What a handler attached for the log (request[ACTIVITY_DETAIL]): the fields it changed
({field, before, after}) and the files it moved, values shortened."""
if not isinstance(detail, dict):
return None
cleaned = {}
if isinstance(detail.get('fields'), list):
cleaned['fields'] = [_short_values(item, ('field', 'before', 'after'))
for item in detail['fields'][:MAX_CHANGES] if isinstance(item, dict)]
if isinstance(detail.get('files'), list):
cleaned['files'] = [_short(name) for name in detail['files'][:MAX_CHANGES]]
return {key: value for key, value in cleaned.items() if value} or None
@web.middleware
async def activity_middleware(request, handler):
response = await handler(request)
action = ROUTE_ACTIONS.get(request.path) if request.method == 'POST' else None
if action and not _failed(response):
try:
target = _target_of(await _request_body(request))
detail = _clean_detail(request.get(ACTIVITY_DETAIL))
await asyncio.get_running_loop().run_in_executor(None, add_entry, 'file', action, target, detail)
except Exception: # noqa: BLE001 - the log must never fail the action it records
logging.getLogger(__name__).warning('Anomalous activity log: could not record %s', request.path, exc_info=True)
return response
def _clean_changes(changes):
cleaned = []
for change in changes[:MAX_CHANGES]:
if not isinstance(change, dict):
continue
item = _short_values(change, ('kind', 'node', 'type', 'widget', 'before', 'after'))
if item.get('kind') in ('changed', 'added', 'removed', 'opened'):
cleaned.append(item)
return cleaned
async def api_get_activity(request):
try:
limit = max(1, min(MAX_ENTRIES, int(request.query.get('limit', '100'))))
except ValueError:
limit = 100
source = request.query.get('source') or None
entries = await asyncio.get_running_loop().run_in_executor(None, list_entries, limit, source)
return web.json_response({'entries': entries})
async def api_post_activity(request):
"""Canvas changes from the browser: {changes: [...], total, workflow}."""
try:
body = await request.json()
except ValueError:
return web.json_response({'error': 'Invalid JSON body'}, status=400)
if not isinstance(body, dict) or not isinstance(body.get('changes'), list):
return web.json_response({'error': 'changes must be a list'}, status=400)
changes = _clean_changes(body['changes'])
if not changes:
return web.json_response({'error': 'No recognizable changes'}, status=400)
total = body.get('total') if isinstance(body.get('total'), int) else len(changes)
detail = {'changes': changes, 'total': max(total, len(changes))}
entry = await asyncio.get_running_loop().run_in_executor(
None, add_entry, 'canvas', 'canvas', body.get('workflow') if isinstance(body.get('workflow'), str) else '', detail)
return web.json_response({'entry': entry})
async def api_clear_activity(request):
await asyncio.get_running_loop().run_in_executor(None, clear)
return web.json_response({'ok': True})
def register_routes(app):
if hasattr(app, 'middlewares') and activity_middleware not in app.middlewares:
app.middlewares.append(activity_middleware)
app.router.add_get('/anomalous/activity', api_get_activity)
app.router.add_post('/anomalous/activity', api_post_activity)
app.router.add_post('/anomalous/activity/clear', api_clear_activity)
+256
View File
@@ -0,0 +1,256 @@
"""Generated audio: the audio gallery, Preview Audio results, and streaming them."""
import asyncio
import os
import shutil
import time
from urllib.parse import quote
import folder_paths
from aiohttp import web
from .audio_metadata import generation_info
from .path_utils import resolve_within
from .trash import move_to_trash, trash_failure
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.flac', '.ogg', '.opus', '.m4a'}
AUDIO_CONTENT_TYPES = {
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.flac': 'audio/flac',
'.ogg': 'audio/ogg',
'.opus': 'audio/ogg',
'.m4a': 'audio/mp4',
}
AUDIO_ROOTS = {
"output": lambda: folder_paths.get_output_directory(),
"temp": lambda: folder_paths.get_temp_directory(),
}
PREVIEW_LIMIT = 30
SAVED_PREVIEW_SUBFOLDER = "audio"
# Revalidate instead of forbidding caches: overwritten files get a new ?v=mtime URL.
AUDIO_CACHE_HEADERS = {"Cache-Control": "no-cache"}
def _audio_url(rel_path, dir_type, version):
return f"/anomalous/audio_stream?path={quote(rel_path, safe='/')}&type={dir_type}&v={int(version)}"
async def api_serve_audio(request):
"""GET /anomalous/audio_stream?path=...&type=output|temp - Stream an audio file."""
rel_path = request.query.get("path", "").strip("/\\")
dir_type = request.query.get("type", "output")
if not rel_path:
return web.Response(status=400, text="Missing audio path")
if dir_type not in AUDIO_ROOTS:
return web.Response(status=400, text="Unknown audio folder")
base_dir = AUDIO_ROOTS[dir_type]()
try:
resolved_path = resolve_within(base_dir, rel_path)
except ValueError:
return web.Response(status=403, text="Forbidden path")
ext = os.path.splitext(resolved_path)[1].lower()
if ext not in AUDIO_EXTENSIONS:
return web.Response(status=415, text="Not an audio file")
if not os.path.isfile(resolved_path):
return web.Response(status=404, text="Audio file not found")
return web.FileResponse(resolved_path, headers={"Content-Type": AUDIO_CONTENT_TYPES[ext], **AUDIO_CACHE_HEADERS})
def _collect_audio_files(root_dir, dir_type):
"""Scan a folder tree for audio files, newest first."""
audios = []
if not os.path.isdir(root_dir):
return audios
for root, _, files in os.walk(root_dir):
for name in files:
if os.path.splitext(name)[1].lower() not in AUDIO_EXTENSIONS:
continue
try:
stat = os.stat(os.path.join(root, name))
except OSError:
continue
subfolder = os.path.relpath(root, root_dir)
clean_sub = "" if subfolder == "." else subfolder.replace(os.sep, '/')
rel_path = "/".join(part for part in (clean_sub, name) if part)
audios.append({
"filename": name,
"subfolder": clean_sub,
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
"audio_url": _audio_url(rel_path, dir_type, stat.st_mtime),
})
audios.sort(key=lambda a: a["mtime"], reverse=True)
return audios
_info_cache = {}
def _cached_generation_info(path, mtime, size):
"""Generation info keyed by (mtime, size) so unchanged files are parsed once."""
cached = _info_cache.get(path)
if cached and cached[0] == (mtime, size):
return cached[1]
info = generation_info(path)
if len(_info_cache) > 5000:
_info_cache.clear()
_info_cache[path] = ((mtime, size), info)
return info
def _attach_generation_info(root_dir, items):
for item in items:
path = os.path.join(root_dir, item["subfolder"], item["filename"])
item["generation"] = _cached_generation_info(path, item["mtime"], item["size_bytes"])
return items
def _search_text(item):
speech = (item.get("generation") or {}).get("speech") or ""
return f"{item['subfolder']}/{item['filename']}\n{speech}".lower()
def _gallery_page(output_dir, page, limit, query):
all_audios = _collect_audio_files(output_dir, "output")
if query:
# Searching reads every file's comments once; the cache keeps later searches cheap.
_attach_generation_info(output_dir, all_audios)
all_audios = [item for item in all_audios if query in _search_text(item)]
total = len(all_audios)
start = (page - 1) * limit
items = all_audios[start:start + limit]
if not query:
_attach_generation_info(output_dir, items)
return items, total, start + limit < total
def _positive_int(value, default, maximum=None):
try:
number = int(value)
except (TypeError, ValueError):
return default
number = max(1, number)
return min(maximum, number) if maximum else number
async def api_get_audio_gallery(request):
"""GET /anomalous/audio_gallery?page=1&limit=50&q=text - Generated audio in output, with the speech that produced it."""
page = _positive_int(request.query.get("page"), 1)
limit = _positive_int(request.query.get("limit"), 40, 100)
query = request.query.get("q", "").strip().lower()
items, total, has_more = await asyncio.to_thread(_gallery_page, folder_paths.get_output_directory(), page, limit, query)
return web.json_response({
"success": True,
"audios": items,
"total": total,
"page": page,
"has_more": has_more,
})
def _list_previews(temp_dir):
items = _collect_audio_files(temp_dir, "temp")[:PREVIEW_LIMIT]
return _attach_generation_info(temp_dir, items)
async def api_get_audio_previews(request):
"""GET /anomalous/audio_previews - Unsaved preview audio in ComfyUI's temp folder (cleared on restart)."""
items = await asyncio.to_thread(_list_previews, folder_paths.get_temp_directory())
return web.json_response({"success": True, "audios": items})
def _save_preview(temp_dir, output_dir, subfolder, filename):
source = resolve_within(temp_dir, subfolder, filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in AUDIO_EXTENSIONS:
raise ValueError("Only audio files can be saved")
if not os.path.isfile(source):
raise FileNotFoundError(filename)
dest_dir = resolve_within(output_dir, SAVED_PREVIEW_SUBFOLDER)
os.makedirs(dest_dir, exist_ok=True)
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(os.path.getmtime(source)))
for counter in range(1, 1000):
name = f"Preview_{stamp}_{counter:02}{ext}"
target = os.path.join(dest_dir, name)
try:
# O_EXCL: never overwrite an existing file in the library.
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0))
except FileExistsError:
continue
try:
with os.fdopen(fd, "wb") as out, open(source, "rb") as src:
shutil.copyfileobj(src, out)
except Exception:
try:
os.remove(target)
except OSError:
pass
raise
stat = os.stat(target)
rel_path = f"{SAVED_PREVIEW_SUBFOLDER}/{name}"
item = {
"filename": name,
"subfolder": SAVED_PREVIEW_SUBFOLDER,
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
"audio_url": _audio_url(rel_path, "output", stat.st_mtime),
}
return _attach_generation_info(output_dir, [item])[0]
raise FileExistsError("No free file name")
async def api_save_audio_preview(request):
"""POST /anomalous/save_audio_preview {filename, subfolder} - Copy a temp preview into output/audio."""
try:
data = await request.json()
except Exception:
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
filename = str(data.get("filename", "")).strip()
subfolder = str(data.get("subfolder", "")).strip().strip("/\\")
if not filename or os.path.basename(filename) != filename:
return web.json_response({"success": False, "error": "Invalid filename"}, status=400)
try:
item = await asyncio.to_thread(
_save_preview, folder_paths.get_temp_directory(), folder_paths.get_output_directory(), subfolder, filename)
except ValueError as e:
return web.json_response({"success": False, "error": str(e)}, status=403)
except FileNotFoundError:
return web.json_response({"success": False, "code": "missing", "error": "Preview no longer exists"}, status=404)
except OSError as e:
return web.json_response({"success": False, "error": f"Failed to save: {e}"}, status=500)
return web.json_response({"success": True, "audio": item})
async def api_delete_audio_gallery(request):
"""POST /anomalous/delete_audio_gallery - Delete one generated audio file."""
try:
data = await request.json()
except Exception:
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
filename = str(data.get("filename", "")).strip()
subfolder = str(data.get("subfolder", "")).strip().strip("/\\")
if not filename or os.path.basename(filename) != filename:
return web.json_response({"success": False, "error": "Invalid filename"}, status=400)
if os.path.splitext(filename)[1].lower() not in AUDIO_EXTENSIONS:
return web.json_response({"success": False, "error": "Only audio files can be deleted here"}, status=415)
try:
target = resolve_within(folder_paths.get_output_directory(), subfolder, filename)
except ValueError:
return web.json_response({"success": False, "error": "Forbidden path"}, status=403)
if not os.path.isfile(target):
return web.json_response({"success": False, "error": "File not found"}, status=404)
try:
await asyncio.to_thread(move_to_trash, target)
except OSError as e:
return web.json_response({"success": False, "error": trash_failure(e)}, status=409)
return web.json_response({"success": True})
+136
View File
@@ -0,0 +1,136 @@
"""Read ComfyUI generation info embedded in saved audio (FLAC and Ogg/Opus comments).
ComfyUI's audio save/preview nodes store the API prompt as a `prompt` comment.
Only the fields the audio gallery shows are extracted; nothing is written.
"""
import json
import os
MAX_TAG_BYTES = 16 * 1024 * 1024
MAX_OGG_SCAN_BYTES = 32 * 1024 * 1024
SPEECH_INPUTS = ("speech", "text", "gen_text")
# "character": Anomalous_TTS (GPT-SoVITS) names the voice by character instead of a file.
SAMPLE_INPUTS = ("sample", "ref_audio", "audio_prompt", "character")
def _parse_vorbis_comment(data, offset=0):
"""Parse a Vorbis comment block into {lowercase key: value}."""
tags = {}
try:
vendor_len = int.from_bytes(data[offset:offset + 4], "little")
pos = offset + 4 + vendor_len
count = int.from_bytes(data[pos:pos + 4], "little")
pos += 4
for _ in range(min(count, 1024)):
length = int.from_bytes(data[pos:pos + 4], "little")
pos += 4
entry = data[pos:pos + length].decode("utf-8", "replace")
pos += length
key, sep, value = entry.partition("=")
if sep:
tags.setdefault(key.lower(), value)
except (IndexError, ValueError):
pass
return tags
def _flac_tags(handle):
if handle.read(4) != b"fLaC":
return {}
while True:
header = handle.read(4)
if len(header) < 4:
return {}
is_last = header[0] & 0x80
block_type = header[0] & 0x7F
length = int.from_bytes(header[1:4], "big")
if block_type == 4:
if length > MAX_TAG_BYTES:
return {}
return _parse_vorbis_comment(handle.read(length))
handle.seek(length, os.SEEK_CUR)
if is_last:
return {}
def _ogg_packets(handle, wanted=2):
"""Yield the first `wanted` logical packets of an Ogg stream."""
packet = b""
scanned = 0
found = 0
while found < wanted and scanned < MAX_OGG_SCAN_BYTES:
header = handle.read(27)
if len(header) < 27 or header[:4] != b"OggS":
return
segments = header[26]
lacing = handle.read(segments)
scanned += 27 + segments
for size in lacing:
packet += handle.read(size)
scanned += size
if size < 255:
yield packet
packet = b""
found += 1
if found >= wanted:
return
if len(packet) > MAX_TAG_BYTES:
return
def _ogg_tags(handle):
packets = list(_ogg_packets(handle, wanted=2))
if len(packets) < 2:
return {}
comment = packets[1]
if comment.startswith(b"OpusTags"):
return _parse_vorbis_comment(comment, 8)
if comment.startswith(b"\x03vorbis"):
return _parse_vorbis_comment(comment, 7)
return {}
def read_audio_tags(path):
"""Return {lowercase key: value} comments for FLAC/Ogg/Opus files, else {}."""
ext = os.path.splitext(path)[1].lower()
try:
with open(path, "rb") as handle:
if ext == ".flac":
return _flac_tags(handle)
if ext in (".ogg", ".opus"):
return _ogg_tags(handle)
except OSError:
pass
return {}
def generation_info(path):
"""Speech text, sample voice and seed of the TTS node that produced this file, if recorded."""
raw = read_audio_tags(path).get("prompt")
if not raw:
return None
try:
prompt = json.loads(raw)
except ValueError:
return None
if not isinstance(prompt, dict):
return None
nodes = [node for node in prompt.values() if isinstance(node, dict) and isinstance(node.get("inputs"), dict)]
# Prefer TTS nodes; otherwise any node with a speech-like text input.
nodes.sort(key=lambda node: "tts" not in str(node.get("class_type", "")).lower())
for node in nodes:
inputs = node["inputs"]
speech = next((inputs[key] for key in SPEECH_INPUTS if isinstance(inputs.get(key), str) and inputs[key].strip()), None)
if speech is None:
continue
sample = next((inputs[key] for key in SAMPLE_INPUTS if isinstance(inputs.get(key), str)), None)
seed = inputs.get("seed")
return {
"node_type": str(node.get("class_type") or ""),
"speech": speech[:4000],
"sample": sample.replace("\\", "/") if sample else None,
"seed": seed if isinstance(seed, (int, float)) else None,
}
return None
+29 -1
View File
@@ -9,7 +9,9 @@ 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 .path_utils import require_filename, resolve_within
from .trash import move_to_trash, trash_failure
GALLERY_SNAPSHOT_SECONDS = 10
@@ -80,9 +82,32 @@ async def api_get_gallery_images(request):
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_dir = folder_paths.get_output_directory()
try:
filename = require_filename(request.query.get('filename'))
file_path = resolve_within(resolve_within(output_dir, request.query.get('subfolder', '')), filename)
except ValueError:
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
if not os.path.isfile(file_path):
return web.json_response({"status": "error", "message": "File not found"}, status=404)
served = file_path
if os.path.splitext(file_path)[1].lower() in CARD_THUMBNAIL_STATIC_EXTENSIONS:
served = await asyncio.to_thread(_build_card_thumbnail, file_path)
# Revalidate (cheap 304s): an output file can be replaced under the same name.
return web.FileResponse(served, headers={'Cache-Control': 'no-cache'})
async def api_delete_gallery_image(request):
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
try:
filename = data.get("filename")
subfolder = data.get("subfolder", "")
@@ -95,7 +120,10 @@ async def api_delete_gallery_image(request):
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
if os.path.exists(file_path):
os.remove(file_path)
try:
await asyncio.to_thread(move_to_trash, file_path)
except OSError as e:
return web.json_response({"status": "error", "message": trash_failure(e)}, status=409)
_invalidate_gallery_snapshot()
return web.json_response({"status": "success"})
else:
+43
View File
@@ -0,0 +1,43 @@
"""What each output image was kept as (GET /anomalous/kept_images), for the gallery's star:
the Workflow Recipe whose source image it is, the combo made from it (a notebook file with
`source_image`), the saved prompt taken from it (a prompt plan material whose source names
the image). Each is {filename, name}, or null when the image was not kept as that."""
import asyncio
from aiohttp import web
from .material_store import _list_materials, get_materials_dir
from .notebooks import _list_notebooks
from .recipe_store import _list_recipes, get_recipes_dir
KINDS = ("recipe", "combo", "prompt")
def _kept_images():
kept = {}
def mark(image, kind, filename, name):
if not isinstance(image, dict) or not isinstance(image.get("filename"), str) or not image["filename"]:
return
subfolder = image.get("subfolder") if isinstance(image.get("subfolder"), str) else ""
entry = kept.setdefault((subfolder, image["filename"]), dict.fromkeys(KINDS))
if entry[kind] is None:
entry[kind] = {"filename": filename, "name": str(name or "")}
for recipe in _list_recipes(get_recipes_dir()):
mark(recipe["data"].get("source_image"), "recipe", recipe["filename"], recipe["data"].get("name"))
for note in _list_notebooks():
mark(note["data"].get("source_image"), "combo", note["filename"], note["name"])
for material in _list_materials(get_materials_dir()):
if material.get("kind") == "prompt_plan":
mark(material.get("source_image"), "prompt", material["filename"], material.get("name"))
return [{"subfolder": subfolder, "filename": filename, **entry} for (subfolder, filename), entry in kept.items()]
async def api_kept_images(request):
try:
images = await asyncio.to_thread(_kept_images)
except (OSError, ValueError):
return web.json_response({"status": "error", "message": "Could not list kept images"}, status=500)
return web.json_response({"status": "success", "images": images})
+82
View File
@@ -0,0 +1,82 @@
"""What only this computer may do. ComfyUI started with --listen lets other devices on the
network open the page: they may look (lists, searches, pictures, covers), but every change
under /anomalous/ (writing, moving and deleting files, downloads, scans, translations, backups,
version switches, saved settings) answers only requests from the computer ComfyUI runs on
(AGENTS.md 安全底线, rule 4). One middleware decides it for all routes: a request other than
GET is local-only unless it is a read sent as POST (OPEN_POSTS); a GET is open unless it is not
a plain read (LOCAL_GETS). A request is local when it comes from a loopback address, through no
proxy, naming a loopback host; checking the Host also keeps out a page that reached this server
under another name (DNS rebinding)."""
import ipaddress
import urllib.parse
from aiohttp import web
PREFIX = "/anomalous/"
PROXY_HEADERS = ("Forwarded", "X-Forwarded-For", "X-Real-IP", "X-Forwarded-Host")
LOCAL_NAMES = ("localhost",)
# Reads sent as POST (their lists are long): open to other devices too. They write nothing.
OPEN_POSTS = frozenset({
"/anomalous/resolve_paths_to_previews",
"/anomalous/resolve_hash_batch",
"/anomalous/inspect_image_material",
})
# GETs that are more than a read: a backup of all your data, a call to GitHub, your folders.
LOCAL_GETS = frozenset({
"/anomalous/backup/file",
"/anomalous/version/releases",
"/anomalous/import/folders",
})
# Routes that answer other devices themselves (MCP replies in JSON-RPC).
OWN_CHECK = frozenset({"/anomalous/mcp"})
READS = ("GET", "HEAD", "OPTIONS")
MESSAGE = ("只能在运行 ComfyUI 的电脑上操作,别的设备只能浏览。"
" Only the computer ComfyUI runs on can do this; other devices can only look.")
def loopback_host(name):
if not name:
return False
name = name.strip("[]").lower()
if name in LOCAL_NAMES:
return True
try:
return ipaddress.ip_address(name).is_loopback
except ValueError:
return False
def is_local(request):
"""The request comes from this computer, directly, and names a loopback host."""
if not loopback_host(request.remote or ""):
return False
if any(header in request.headers for header in PROXY_HEADERS):
return False
return loopback_host(urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname)
def needs_local(method, path):
"""Whether a request to `path` with `method` must come from this computer."""
if not path.startswith(PREFIX) or path in OWN_CHECK:
return False
if method.upper() in READS:
return path in LOCAL_GETS
return path not in OPEN_POSTS
def refusal():
return web.json_response({"status": "error", "success": False, "error": "local_only", "code": "local_only",
"message": MESSAGE}, status=403)
@web.middleware
async def local_only_middleware(request, handler):
if needs_local(request.method, request.path) and not is_local(request):
return refusal()
return await handler(request)
async def api_access(request):
"""GET /anomalous/access - whether this page may change things: {"local": bool}."""
return web.json_response({"local": is_local(request)})
+2
View File
@@ -25,6 +25,8 @@ def _material_assets_dir(materials_dir, filename, create=False):
def _inspect_source_image(source_image):
source = _normalise_source_image(source_image)
if source is None:
raise ValueError("Missing source image")
if not source["filename"].lower().endswith(".png"):
raise ValueError("Only PNG output images can contain reusable workflow metadata")
source_path = _output_source_path(source)
+10
View File
@@ -371,6 +371,14 @@ def _recipe_link_fingerprint(recipe):
return hashlib.sha256(payload).hexdigest()
def _summary_source_image(source):
"""The output image a material was taken from, as {filename, subfolder}, or None."""
image = source.get("image")
if not isinstance(image, dict) or not isinstance(image.get("filename"), str) or not image["filename"]:
return None
return {"filename": image["filename"], "subfolder": image.get("subfolder") if isinstance(image.get("subfolder"), str) else ""}
def _material_summary(filename, material):
image = material.get("image") or {}
source = material.get("source") if isinstance(material.get("source"), dict) else {}
@@ -398,6 +406,8 @@ def _material_summary(filename, material):
"label": str(source.get("parameter_name") or source.get("recipe_name") or source.get("notebook_name") or "").strip(),
},
"source_fingerprint": str(source.get("parameter_signature") or image.get("source_sha256") or ""),
"moved_to_recipe": str(material.get("moved_to_recipe") or ""),
"source_image": _summary_source_image(source),
}
if source.get("recipe_filename"):
summary["source_recipe"] = {
+17 -50
View File
@@ -19,11 +19,10 @@ from .material_schema import (
_normalise_prompt_plan,
_recipe_link_fingerprint,
)
from .parameters import get_parameters_dir
from .recipe_constants import MAX_RECIPE_BYTES
from .recipe_store import _read_recipe, get_recipes_dir
from .trash import move_to_trash
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
from .workflow_schema import _parameter_signature, _validate_workflow
_material_write_lock = threading.RLock()
@@ -58,47 +57,6 @@ def _read_material(path):
return value
def _read_parameter_source(recipe_filename, parameter_filename=None):
recipe_filename = require_filename(recipe_filename)
if not recipe_filename.endswith(".json") or recipe_filename.startswith("."):
raise ValueError("Invalid recipe filename")
recipe_path = resolve_within(get_recipes_dir(), recipe_filename)
if os.path.getsize(recipe_path) > MAX_RECIPE_BYTES:
raise ValueError("Recipe source is too large")
recipe = _read_recipe(recipe_path)
source = recipe
if parameter_filename:
parameter_filename = require_filename(parameter_filename)
if not parameter_filename.endswith(".json") or parameter_filename.startswith("."):
raise ValueError("Invalid parameter filename")
parameter_path = resolve_within(get_parameters_dir(), parameter_filename)
if os.path.getsize(parameter_path) > MAX_RECIPE_BYTES:
raise ValueError("Parameter notebook is too large")
with open(parameter_path, "r", encoding="utf-8") as parameter_file:
source = json.load(parameter_file)
if not isinstance(source, dict) or source.get("recipe_filename") != recipe_filename:
raise ValueError("Parameter notebook does not belong to recipe")
workflow = source.get("workflow") if isinstance(source, dict) else None
if not isinstance(workflow, dict):
raise ValueError("Parameter source has no workflow")
_validate_workflow(workflow)
return recipe_filename, parameter_filename, recipe, source, workflow
def _parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow):
record = {
"type": "recipe_parameter_notebook" if parameter_filename else "workflow_recipe",
**_snapshot_recipe_source(recipe_filename),
"parameter_signature": (_parameter_signature(workflow) or {}).get("value") or "",
}
if parameter_filename:
record["parameter_filename"] = parameter_filename
record["parameter_name"] = str(source.get("name") or "").strip()[:200]
elif isinstance(recipe, dict):
record["parameter_name"] = str(recipe.get("name") or "").strip()[:200]
return record
def _snapshot_recipe_source(recipe_filename):
if not recipe_filename:
return {}
@@ -253,14 +211,17 @@ def _query_materials(materials_dir, query):
if len(node_type) > 200:
raise ValueError("Invalid node type")
if len(search) > 200 or len(tag) > 60 or kind not in (
"", "image_workflow_snapshot", "image_node_selection", "recipe_parameter_selection", "prompt_note_bundle", "prompt_text", "prompt_plan"
"", "image_workflow_snapshot", "image_node_selection", "recipe_parameter_selection", "node_parameter_selection",
"prompt_note_bundle", "prompt_text", "prompt_plan"
):
raise ValueError("Invalid material filter")
# Whole workflows live with the recipes: "all" is the pieces, and one moved there is not listed.
materials = [material for material in materials
if (not search or search in " ".join([material["name"], *material["node_types"], *material.get("tags", [])]).casefold())
if not material.get("moved_to_recipe")
and (not search or search in " ".join([material["name"], *material["node_types"], *material.get("tags", [])]).casefold())
and (not tag or tag in [value.casefold() for value in material.get("tags", [])])
and (not kind or material["kind"] == kind)
and (category == "all" or _material_category(material) == category)
and (_material_category(material) == category if category != "all" else _material_category(material) != "workflow")
and (not node_type or node_type in material["node_types"])]
total = len(materials)
limit = min(100, max(1, int(query.get("limit", 48))))
@@ -288,10 +249,16 @@ def _update_material_details(materials_dir, filename, name, tags, prompt_role_ov
return _with_live_recipe_source(_material_summary(filename, material))
def _mark_material_moved(materials_dir, filename, recipe_filename):
"""Notes that a kept workflow now lives as recipe `recipe_filename`; the material file stays."""
with _material_write_lock:
path = resolve_within(materials_dir, filename)
material = _read_material(path)
material["moved_to_recipe"] = recipe_filename
_atomic_write_json(path, material)
def _delete_material(materials_dir, filename, path):
with _material_write_lock:
_read_material(path)
os.remove(path)
assets_dir = _material_assets_dir(materials_dir, filename)
if os.path.isdir(assets_dir):
shutil.rmtree(assets_dir)
move_to_trash(path, _material_assets_dir(materials_dir, filename))
+25 -148
View File
@@ -25,16 +25,13 @@ from .material_schema import (
_normalise_material_tags,
_normalise_prompt_role_overrides,
_normalise_selected_node_ids,
_parameter_signature,
_prompt_excerpt,
_prompt_groups_from_roles,
_prompt_roles_for_workflow,
_workflow_hashes_for_blocks,
)
from .notebooks import MAX_NOTEBOOK_BYTES
from .parameters import get_parameters_dir
from .recipe_constants import MAX_RECIPE_BYTES
from .recipe_schema import _build_model_references
from .recipe_schema import _build_model_references, _normalise_source_image
from .recipe_store import get_recipes_dir
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
@@ -43,8 +40,6 @@ MATERIAL_SCHEMA_VERSION = 1
_store_get_materials_dir = _store.get_materials_dir
_store_read_material = _store._read_material
_store_read_parameter_source = _store._read_parameter_source
_store_parameter_source_record = _store._parameter_source_record
_store_snapshot_recipe_source = _store._snapshot_recipe_source
_store_live_recipe_source = _store._live_recipe_source
_store_with_live_recipe_source = _store._with_live_recipe_source
@@ -89,16 +84,6 @@ def _read_material(path):
return _store_read_material(path)
def _read_parameter_source(recipe_filename, parameter_filename=None):
_store.get_recipes_dir = get_recipes_dir
_store.get_parameters_dir = get_parameters_dir
return _store_read_parameter_source(recipe_filename, parameter_filename)
def _parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow):
return _store_parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow)
def _snapshot_recipe_source(filename):
_store.get_recipes_dir = get_recipes_dir
return _store_snapshot_recipe_source(filename)
@@ -263,136 +248,6 @@ async def api_save_image_material(request):
})
async def api_save_parameter_material(request):
try:
payload = await request.json()
recipe_filename, parameter_filename, recipe, source, workflow = await asyncio.to_thread(
_read_parameter_source,
payload.get("recipe_filename", ""),
payload.get("parameter_filename"),
)
source_name = str(source.get("name") or recipe.get("name") or "参数素材").strip()
name = payload.get("name") or source_name
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material name")
tags = _normalise_material_tags(payload.get("tags", recipe.get("tags") or []))
allow_duplicate = payload.get("allow_duplicate", False)
if not isinstance(allow_duplicate, bool):
raise ValueError("Invalid duplicate preference")
reusable_ids = [
node.get("id") for node in workflow.get("nodes", [])
if isinstance(node, dict) and node.get("id") is not None
and isinstance(node.get("widgets_values"), list) and node.get("widgets_values")
]
raw_selection = payload.get("selected_node_ids")
selected_node_ids = _normalise_selected_node_ids(
workflow,
reusable_ids if raw_selection is None else raw_selection,
)
reusable_keys = {str(value) for value in reusable_ids}
if any(str(value) not in reusable_keys for value in selected_node_ids):
raise ValueError("Selected node has no reusable parameters")
selected_keys = {str(value) for value in selected_node_ids}
blocks = [
block for block in _node_blocks(workflow, include_values=False)
if str(block.get("node_id")) in selected_keys
]
references = [
reference for reference in _build_model_references(source, verify_identities=False)
if str(reference.get("node_id")) in selected_keys
]
source_record = _parameter_source_record(
recipe_filename, parameter_filename, recipe, source, workflow
)
material_id = uuid.uuid4().hex
filename = f"material_{int(time.time())}_{material_id}.json"
material = {
"schema_version": MATERIAL_SCHEMA_VERSION,
"id": material_id,
"kind": "recipe_parameter_selection",
"name": name,
"tags": tags,
"timestamp": int(time.time() * 1000),
"source": source_record,
"workflow": workflow,
"model_references": references,
"capabilities": ["apply_node_parameters"],
"selection": {"scope": "nodes", "node_ids": selected_node_ids},
}
params = source.get("params") if isinstance(source.get("params"), dict) else {}
prompt_role_overrides = _normalise_prompt_role_overrides(params.get("promptRoleOverrides"))
if prompt_role_overrides:
material["promptRoleOverrides"] = prompt_role_overrides
materials_dir = get_materials_dir()
duplicate = await asyncio.to_thread(
_persist_parameter_material, materials_dir, filename, material, allow_duplicate
)
if duplicate:
return web.json_response({
"status": "duplicate",
"filename": duplicate["filename"],
"name": duplicate["name"],
}, status=409)
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Could not save parameter material"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Parameter source not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not save parameter material"}, status=500)
return web.json_response({
"status": "success",
"filename": filename,
"material": _with_live_recipe_source(_material_summary(filename, material)),
"node_blocks": blocks,
})
async def api_save_prompt_note_material(request):
try:
payload = await request.json()
scope = payload.get("scope", "note")
if scope not in ("note", "prompt"):
raise ValueError("Invalid prompt scope")
source_filename = require_filename(payload.get("notebook_filename", ""))
if not source_filename.endswith(".json") or source_filename.startswith("."):
raise ValueError("Invalid notebook filename")
name = payload.get("name", "")
if not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material name")
note = _normalise_prompt_note(payload.get("note"), scope == "prompt")
if scope == "prompt" and not note["promptEn"].strip():
raise ValueError("Prompt is empty")
tags = _normalise_material_tags(payload.get("tags", []))
allow_duplicate = payload.get("allow_duplicate", False)
if not isinstance(allow_duplicate, bool):
raise ValueError("Invalid duplicate preference")
signature = hashlib.sha256(json.dumps(note, ensure_ascii=False, sort_keys=True, allow_nan=False).encode("utf-8")).hexdigest()
material_id = uuid.uuid4().hex
filename = f"material_{int(time.time())}_{material_id}.json"
material = {
"schema_version": MATERIAL_SCHEMA_VERSION, "id": material_id,
"kind": "prompt_text" if scope == "prompt" else "prompt_note_bundle",
"name": name.strip(), "tags": tags, "timestamp": int(time.time() * 1000),
"source": {"type": "prompt_note", "notebook_filename": source_filename,
"notebook_name": name.strip(), "parameter_signature": signature},
"note": note, "selection": {"scope": scope},
"capabilities": ["copy_prompt", "restore_prompt_note"],
}
duplicate = await asyncio.to_thread(
_persist_parameter_material, get_materials_dir(), filename, material, allow_duplicate
)
if duplicate:
return web.json_response({"status": "duplicate", "filename": duplicate["filename"],
"name": duplicate["name"]}, status=409)
return web.json_response({"status": "success", "filename": filename,
"material": _material_summary(filename, material)})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid prompt note material"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not save prompt note material"}, status=500)
async def api_save_prompt_plan(request):
try:
payload = await request.json()
@@ -404,13 +259,16 @@ async def api_save_prompt_plan(request):
allow_duplicate = payload.get("allow_duplicate", False)
if not isinstance(allow_duplicate, bool):
raise ValueError("Invalid duplicate preference")
# The output image the prompts were taken from, so the gallery can show it was kept.
source_image = _normalise_source_image(payload.get("source_image"))
signature = hashlib.sha256(json.dumps(plan, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
material_id = uuid.uuid4().hex
filename = f"material_{int(time.time())}_{material_id}.json"
material = {"schema_version": MATERIAL_SCHEMA_VERSION, "id": material_id,
"kind": "prompt_plan", "name": name.strip(), "tags": tags,
"timestamp": int(time.time() * 1000), "plan": plan,
"source": {"type": "prompt_plan", "parameter_signature": signature},
"source": {"type": "prompt_plan", "parameter_signature": signature,
**({"image": source_image} if source_image else {})},
"capabilities": ["compose_prompt"], "selection": {"scope": "prompt_plan"}}
duplicate = await asyncio.to_thread(_persist_parameter_material, get_materials_dir(), filename, material, allow_duplicate)
if duplicate:
@@ -432,6 +290,24 @@ async def api_get_materials(request):
return web.json_response({"status": "error", "message": "Could not list materials"}, status=500)
async def api_mark_material_moved(request):
"""POST {filename, recipe}: the kept workflow `filename` was saved as recipe `recipe`."""
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
recipe = require_filename(payload.get("recipe", ""))
if not filename.endswith(".json") or not recipe.endswith(".json"):
raise ValueError("Invalid material")
await asyncio.to_thread(_store._mark_material_moved, get_materials_dir(), filename, recipe)
return web.json_response({"status": "success"})
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid material"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not update material"}, status=500)
async def api_update_material(request):
try:
payload = await request.json()
@@ -514,7 +390,8 @@ async def api_get_materials_by_node_type(request):
def collect():
matches = []
for summary in _list_materials(materials_dir):
if node_type not in summary["node_types"]:
# A whole workflow moved to a recipe is offered through the recipe's own sets.
if node_type not in summary["node_types"] or summary.get("moved_to_recipe"):
continue
try:
material = _read_material(resolve_within(materials_dir, summary["filename"]))
+195 -44
View File
@@ -1,11 +1,20 @@
"""Model cover, thumbnail-cache, and generated-image lookup routes."""
"""Model cover, thumbnail-cache, and generated-image lookup routes.
Card images are 512 px WebP copies of the covers (a video cover gets one of its first
frame, the poster), kept in the user folder so they survive ComfyUI restarts (ComfyUI
empties its temp folder on every start). Listing a folder queues the covers not made yet
for one background worker, so scrolling and the next visit find them ready.
"""
import asyncio
import collections
import hashlib
import json
import os
import struct
import tempfile
import threading
import time
import urllib.parse
from aiohttp import web
@@ -17,6 +26,8 @@ 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_WARM_QUEUE_LIMIT = 512
_thumbnail_cleanup_lock = threading.Lock()
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
_thumbnail_last_cleanup = 0.0
@@ -24,17 +35,53 @@ _thumbnail_created_since_cleanup = 0
def _thumbnail_cache_directory():
"""<user folder>/anomalous/cache/card_thumbnails; the system temp folder if there is none."""
try:
base_dir = folder_paths.get_temp_directory()
user_dir = folder_paths.get_user_directory()
except Exception:
base_dir = None
if not isinstance(base_dir, (str, os.PathLike)):
base_dir = tempfile.gettempdir()
cache_dir = os.path.join(base_dir, "anomalous_model_browser", "card_thumbnails")
user_dir = None
if isinstance(user_dir, (str, os.PathLike)):
cache_dir = os.path.join(user_dir, "anomalous", "cache", "card_thumbnails")
else:
cache_dir = os.path.join(tempfile.gettempdir(), "anomalous_model_browser", "card_thumbnails")
os.makedirs(cache_dir, exist_ok=True)
return cache_dir
def thumbnail_cache_usage():
"""(file count, bytes) of the card image cache, for the settings page."""
count = size = 0
try:
with os.scandir(_thumbnail_cache_directory()) as iterator:
for entry in iterator:
if entry.is_file() and entry.name.endswith('.webp'):
count += 1
try:
size += entry.stat().st_size
except OSError:
pass
except OSError:
pass
return count, size
def clear_thumbnail_cache():
"""Delete the derived card images (never a cover); they are made again when needed."""
removed = 0
try:
with os.scandir(_thumbnail_cache_directory()) as iterator:
for entry in iterator:
if entry.is_file() and entry.name.endswith('.webp'):
try:
os.remove(entry.path)
removed += 1
except OSError:
pass
except OSError:
pass
return removed
def _prune_thumbnail_cache(cache_dir, created=False):
"""Occasionally cap the derived thumbnail cache without touching source covers."""
global _thumbnail_last_cleanup, _thumbnail_created_since_cleanup
@@ -78,28 +125,61 @@ def _prune_thumbnail_cache(cache_dir, created=False):
break
def _cached_card_path(source_path, kind=''):
"""Where the card image of `source_path` is cached: keyed by the file's identity and edge."""
source_stat = os.stat(source_path)
cache_key = "\0".join((
os.path.realpath(source_path),
str(source_stat.st_size),
str(source_stat.st_mtime_ns),
str(getattr(source_stat, 'st_ctime_ns', 0)),
str(CARD_THUMBNAIL_EDGE),
*((kind,) if kind else ()),
))
digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest()
cache_dir = _thumbnail_cache_directory()
return cache_dir, os.path.join(cache_dir, f"{digest}.webp")
def _reuse_cached(cache_dir, cached_path):
if not os.path.isfile(cached_path):
return False
try:
os.utime(cached_path, None)
except OSError:
pass
_prune_thumbnail_cache(cache_dir)
return True
def _save_card_image(image, cache_dir, cached_path):
"""Shrink a PIL image to the card edge and store it as WebP (atomically)."""
from PIL import Image
resampling = getattr(Image, 'Resampling', Image).LANCZOS
image.thumbnail((CARD_THUMBNAIL_EDGE, CARD_THUMBNAIL_EDGE), resampling)
if image.mode not in ('RGB', 'RGBA'):
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
temp_path = f"{cached_path}.{os.getpid()}.{threading.get_ident()}.tmp"
try:
image.save(temp_path, format='WEBP', quality=84, method=4)
os.replace(temp_path, cached_path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
_prune_thumbnail_cache(cache_dir, created=True)
def _build_card_thumbnail_impl(source_path):
"""Return a cached 512px WebP card image, or the original on any safe fallback."""
try:
from PIL import Image, ImageOps
source_stat = os.stat(source_path)
cache_key = "\0".join((
os.path.realpath(source_path),
str(source_stat.st_size),
str(source_stat.st_mtime_ns),
str(getattr(source_stat, 'st_ctime_ns', 0)),
str(CARD_THUMBNAIL_EDGE),
))
digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest()
cache_dir = _thumbnail_cache_directory()
cached_path = os.path.join(cache_dir, f"{digest}.webp")
if os.path.isfile(cached_path):
try:
os.utime(cached_path, None)
except OSError:
pass
_prune_thumbnail_cache(cache_dir)
cache_dir, cached_path = _cached_card_path(source_path)
if _reuse_cached(cache_dir, cached_path):
return cached_path
with Image.open(source_path) as image:
@@ -108,23 +188,7 @@ def _build_card_thumbnail_impl(source_path):
image = ImageOps.exif_transpose(image)
if max(image.size) <= CARD_THUMBNAIL_EDGE:
return source_path
resampling = getattr(Image, 'Resampling', Image).LANCZOS
image.thumbnail((CARD_THUMBNAIL_EDGE, CARD_THUMBNAIL_EDGE), resampling)
if image.mode not in ('RGB', 'RGBA'):
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
temp_path = (
f"{cached_path}.{os.getpid()}.{threading.get_ident()}.tmp"
)
try:
image.save(temp_path, format='WEBP', quality=84, method=4)
os.replace(temp_path, cached_path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
_prune_thumbnail_cache(cache_dir, created=True)
_save_card_image(image, cache_dir, cached_path)
return cached_path
except Exception:
return source_path
@@ -136,6 +200,77 @@ def _build_card_thumbnail(source_path):
return _build_card_thumbnail_impl(source_path)
def _build_video_poster_impl(source_path):
"""A cached 512 px WebP of a video cover's first frame, or None (no decoder, bad file)."""
try:
cache_dir, cached_path = _cached_card_path(source_path, 'poster')
if _reuse_cached(cache_dir, cached_path):
return cached_path
import av # PyAV ships with ComfyUI for its video nodes
with av.open(source_path) as container:
stream = next((s for s in container.streams if s.type == 'video'), None)
if stream is None:
return None
stream.thread_type = 'AUTO'
frame = next(container.decode(stream), None)
if frame is None:
return None
image = frame.to_image()
_save_card_image(image, cache_dir, cached_path)
return cached_path
except Exception:
return None
def _build_video_poster(source_path):
with _thumbnail_generation_slots:
return _build_video_poster_impl(source_path)
# ---------- background warm-up of card images ----------
_warm_queue = collections.deque()
_warm_pending = set()
_warm_lock = threading.Lock()
_warm_thread = None
def _warm_worker():
"""One thread, one decode at a time, a short rest between: never more than half the slots."""
global _warm_thread
while True:
with _warm_lock:
if not _warm_queue:
_warm_thread = None
return
path = _warm_queue.popleft()
_warm_pending.discard(path)
ext = os.path.splitext(path)[1].lower()
if ext in CARD_POSTER_VIDEO_EXTENSIONS:
_build_video_poster(path)
else:
_build_card_thumbnail(path)
time.sleep(0.02)
def queue_card_images(paths):
"""Make the card images of these covers in the background (bounded, deduplicated)."""
global _warm_thread
with _warm_lock:
for path in paths:
ext = os.path.splitext(path)[1].lower()
if ext not in CARD_THUMBNAIL_STATIC_EXTENSIONS and ext not in CARD_POSTER_VIDEO_EXTENSIONS:
continue
if path in _warm_pending or len(_warm_queue) >= CARD_WARM_QUEUE_LIMIT:
continue
_warm_pending.add(path)
_warm_queue.append(path)
if _warm_queue and _warm_thread is None:
_warm_thread = threading.Thread(target=_warm_worker, name="anomalous-card-images", daemon=True)
_warm_thread.start()
async def api_serve_image(request):
"""Dedicated image serving endpoint for model preview images."""
folder_type = request.query.get('type', 'checkpoints')
@@ -174,13 +309,17 @@ async def api_serve_image(request):
return web.Response(status=415, text='Unsupported media type')
served_path = file_path
if (
request.query.get('variant') == 'card'
and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS
):
variant = request.query.get('variant')
if variant == 'card' and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS:
served_path = await asyncio.to_thread(_build_card_thumbnail, file_path)
if served_path != file_path:
content_type = 'image/webp'
elif variant == 'poster':
# The card's still image of a video cover; nothing (404) when it cannot be read.
poster = await asyncio.to_thread(_build_video_poster, file_path) if ext in CARD_POSTER_VIDEO_EXTENSIONS else None
if not poster:
return web.Response(status=404, text='No poster')
served_path, content_type = poster, 'image/webp'
headers = {'Content-Type': content_type}
if request.query.get('t'):
@@ -188,6 +327,18 @@ async def api_serve_image(request):
return web.FileResponse(served_path, headers=headers)
async def api_card_cache(request):
"""GET /anomalous/card_cache - how many card images are cached and their size."""
count, size = await asyncio.to_thread(thumbnail_cache_usage)
return web.json_response({"count": count, "bytes": size, "limit": CARD_THUMBNAIL_CACHE_LIMIT})
async def api_clear_card_cache(request):
"""POST /anomalous/card_cache/clear - delete the derived card images (made again when shown)."""
removed = await asyncio.to_thread(clear_thumbnail_cache)
return web.json_response({"status": "ok", "removed": removed})
async def api_clear_cache(request):
try:
from .metadata import clear_metadata_cache
+210 -191
View File
@@ -4,198 +4,217 @@ import json
import urllib.parse
import subprocess
import threading
import asyncio
import copy
from functools import lru_cache
from aiohttp import web
import asyncio
import copy
from functools import lru_cache
from aiohttp import web
import folder_paths
import struct
try:
from ..model_identity import sidecar_file_hash
except ImportError:
from model_identity import sidecar_file_hash
def _select_info_file(data, file_path):
"""Select the Civitai file entry that actually describes ``file_path``.
A model version may contain a checkpoint, text encoder, and VAE in the same
``files`` array. Taking the first SHA256 silently assigns the dependency's
hash to every local sidecar copied from that version. Physical byte size is
the strongest cheap discriminator available here and remains valid after a
local rename. Filenames are intentionally excluded: local renames are the
reason provenance recovery exists and cannot be identity evidence.
"""
entries = []
for file_info in data.get("files", []):
if not isinstance(file_info, dict):
continue
hashes = file_info.get("hashes", {})
sha256 = hashes.get("SHA256") if isinstance(hashes, dict) else None
if not sha256:
continue
size_bytes = None
try:
if file_info.get("sizeKB") is not None:
size_bytes = int(round(float(file_info["sizeKB"]) * 1024))
except (TypeError, ValueError, OverflowError):
pass
entries.append((file_info, str(sha256), size_bytes))
if not entries:
return None
try:
physical_size = os.path.getsize(file_path)
except OSError:
physical_size = None
if physical_size is not None:
size_matches = [entry for entry in entries if entry[2] == physical_size]
if len(size_matches) == 1:
return size_matches[0][0]
if len(size_matches) > 1:
return None
# Offline-generated metadata commonly contains one injected hash without
# Civitai size/name data. It is safe only when there is a single candidate.
if len(entries) == 1:
return entries[0][0]
return None
def _select_info_hash(data, file_path):
selected = _select_info_file(data, file_path)
if not selected:
return ""
hashes = selected.get("hashes", {})
return str(hashes.get("SHA256", "")) if isinstance(hashes, dict) else ""
def _positive_civitai_id(value):
"""Return a real Civitai identifier, excluding offline sentinel values."""
if isinstance(value, bool):
return None
try:
parsed = int(value)
except (TypeError, ValueError, OverflowError):
return None
return parsed if parsed > 0 else None
def _read_metadata(file_path):
base_path = os.path.splitext(file_path)[0]
metadata = {
"name": os.path.basename(base_path),
"description": "",
"notes": "",
"trainedWords": [],
"baseModel": "",
"civitai_url": "",
"source_url": "",
"hash": "",
"hash_algorithm": "sha256",
"hash_scope": "file",
"hash_source": "",
"custom_name": "",
"custom_notes": ""
}
info_files = [f"{base_path}.info", f"{base_path}.civitai.info"]
for info_file in info_files:
if os.path.exists(info_file):
try:
with open(info_file, 'r', encoding='utf-8') as f:
data = json.load(f)
name = data.get("name", "")
if "model" in data and isinstance(data["model"], dict):
model_name = data["model"].get("name", "")
name = f"{model_name} - {name}".strip(' -')
description = data.get("description", "") or ""
if not description and "model" in data and isinstance(data["model"], dict):
description = data["model"].get("description", "")
notes = data.get("notes", "")
trained_words = data.get("trainedWords", [])
base_model = data.get("baseModel", "")
model_id = _positive_civitai_id(data.get("modelId"))
version_id = _positive_civitai_id(data.get("id"))
civitai_url = ""
if model_id is not None:
# Handle Civitai's new mature content policy
nsfw_level = data.get("nsfwLevel", 1)
is_nsfw = False
if "model" in data and isinstance(data["model"], dict):
is_nsfw = data["model"].get("nsfw", False)
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
civitai_url = f"https://{domain}/models/{model_id}"
if version_id is not None:
civitai_url += f"?modelVersionId={version_id}"
selected_file = _select_info_file(data, file_path)
hash_val, hash_source = sidecar_file_hash(data, file_path, selected_file)
if hash_val:
metadata["hash_source"] = hash_source
if name: metadata["name"] = name
if description: metadata["description"] = description
if notes: metadata["notes"] = notes
if trained_words: metadata["trainedWords"] = trained_words
if base_model: metadata["baseModel"] = base_model
if civitai_url: metadata["civitai_url"] = civitai_url
if model_id is not None: metadata["model_id"] = model_id
if version_id is not None: metadata["version_id"] = version_id
if hash_val: metadata["hash"] = hash_val
if "anomalous_custom_name" in data and data["anomalous_custom_name"]: metadata["custom_name"] = data["anomalous_custom_name"]
if "anomalous_custom_notes" in data and data["anomalous_custom_notes"]: metadata["custom_notes"] = data["anomalous_custom_notes"]
if "anomalous_source_url" in data and data["anomalous_source_url"]: metadata["source_url"] = data["anomalous_source_url"]
except Exception:
pass
return metadata
def _file_signature(file_path):
try:
stat = os.stat(file_path)
return (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
except OSError:
return None
def _metadata_signature(file_path):
base_path = os.path.splitext(file_path)[0]
return (
_file_signature(file_path),
_file_signature(f"{base_path}.info"),
_file_signature(f"{base_path}.civitai.info"),
)
@lru_cache(maxsize=4096)
def _get_metadata_cached(file_path, signature):
# ``signature`` is deliberately unused by the parser itself; it is part of
# the cache key and changes whenever the model or either metadata sidecar
# changes. The cache is bounded so removed/renamed models cannot grow it
# without limit.
return _read_metadata(file_path)
def get_metadata(file_path):
normalized_path = os.path.realpath(file_path)
signature = _metadata_signature(normalized_path)
# Metadata contains mutable lists. Never expose the cached object directly.
return copy.deepcopy(_get_metadata_cached(normalized_path, signature))
def clear_metadata_cache():
_get_metadata_cached.cache_clear()
def get_metadata_cache_info():
return _get_metadata_cached.cache_info()
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
# Fields the model editor sets: <model>.anomalous.json key -> metadata key.
USER_FIELDS = {"custom_name": "custom_name", "custom_notes": "custom_notes", "source_url": "source_url"}
def _select_info_file(data, file_path):
"""Select the Civitai file entry that actually describes ``file_path``.
A model version may contain a checkpoint, text encoder, and VAE in the same
``files`` array. Taking the first SHA256 silently assigns the dependency's
hash to every local sidecar copied from that version. Physical byte size is
the strongest cheap discriminator available here and remains valid after a
local rename. Filenames are intentionally excluded: local renames are the
reason provenance recovery exists and cannot be identity evidence.
"""
entries = []
for file_info in data.get("files", []):
if not isinstance(file_info, dict):
continue
hashes = file_info.get("hashes", {})
sha256 = hashes.get("SHA256") if isinstance(hashes, dict) else None
if not sha256:
continue
size_bytes = None
try:
if file_info.get("sizeKB") is not None:
size_bytes = int(round(float(file_info["sizeKB"]) * 1024))
except (TypeError, ValueError, OverflowError):
pass
entries.append((file_info, str(sha256), size_bytes))
if not entries:
return None
try:
physical_size = os.path.getsize(file_path)
except OSError:
physical_size = None
if physical_size is not None:
size_matches = [entry for entry in entries if entry[2] == physical_size]
if len(size_matches) == 1:
return size_matches[0][0]
if len(size_matches) > 1:
return None
# Offline-generated metadata commonly contains one injected hash without
# Civitai size/name data. It is safe only when there is a single candidate.
if len(entries) == 1:
return entries[0][0]
return None
def _select_info_hash(data, file_path):
selected = _select_info_file(data, file_path)
if not selected:
return ""
hashes = selected.get("hashes", {})
return str(hashes.get("SHA256", "")) if isinstance(hashes, dict) else ""
def _positive_civitai_id(value):
"""Return a real Civitai identifier, excluding offline sentinel values."""
if isinstance(value, bool):
return None
try:
parsed = int(value)
except (TypeError, ValueError, OverflowError):
return None
return parsed if parsed > 0 else None
def _read_metadata(file_path):
base_path = os.path.splitext(file_path)[0]
metadata = {
"name": os.path.basename(base_path),
"description": "",
"notes": "",
"trainedWords": [],
"baseModel": "",
"civitai_url": "",
"source_url": "",
"hash": "",
"hash_algorithm": "sha256",
"hash_scope": "file",
"hash_source": "",
"custom_name": "",
"custom_notes": "",
# "civitai" (matched), "local" (only what the file tells: no match, or offline) or "" (not scanned)
"info_source": "",
"unmatched_reason": "", # for "local": not_found | network | offline | "" (scanned before this was kept)
"user_fields": [],
}
info_files = [f"{base_path}.info", f"{base_path}.civitai.info"]
for info_file in info_files:
if os.path.exists(info_file):
try:
with open(info_file, 'r', encoding='utf-8') as f:
data = json.load(f)
name = data.get("name", "")
if "model" in data and isinstance(data["model"], dict):
model_name = data["model"].get("name", "")
# A model and version of one name (what the file alone tells) read once.
name = f"{model_name} - {name}" if model_name and name and model_name != name else model_name or name
description = data.get("description", "") or ""
if not description and "model" in data and isinstance(data["model"], dict):
description = data["model"].get("description", "")
notes = data.get("notes", "")
trained_words = data.get("trainedWords", [])
base_model = data.get("baseModel", "")
model_id = _positive_civitai_id(data.get("modelId"))
version_id = _positive_civitai_id(data.get("id"))
civitai_url = ""
if model_id is not None:
# Handle Civitai's new mature content policy
nsfw_level = data.get("nsfwLevel", 1)
is_nsfw = False
if "model" in data and isinstance(data["model"], dict):
is_nsfw = data["model"].get("nsfw", False)
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
civitai_url = f"https://{domain}/models/{model_id}"
if version_id is not None:
civitai_url += f"?modelVersionId={version_id}"
selected_file = _select_info_file(data, file_path)
hash_val, hash_source = sidecar_file_hash(data, file_path, selected_file)
if hash_val:
metadata["hash_source"] = hash_source
if name: metadata["name"] = name
if description: metadata["description"] = description
if notes: metadata["notes"] = notes
if trained_words: metadata["trainedWords"] = trained_words
if base_model: metadata["baseModel"] = base_model
if civitai_url: metadata["civitai_url"] = civitai_url
if model_id is not None: metadata["model_id"] = model_id
if version_id is not None: metadata["version_id"] = version_id
if hash_val: metadata["hash"] = hash_val
if "anomalous_custom_name" in data and data["anomalous_custom_name"]: metadata["custom_name"] = data["anomalous_custom_name"]
if "anomalous_custom_notes" in data and data["anomalous_custom_notes"]: metadata["custom_notes"] = data["anomalous_custom_notes"]
if "anomalous_source_url" in data and data["anomalous_source_url"]: metadata["source_url"] = data["anomalous_source_url"]
except Exception:
pass
scan = sidecar_info(base_path)
if scan is not None:
metadata["info_source"] = "local" if is_unmatched(scan) else "civitai"
metadata["unmatched_reason"] = unmatched_reason(scan)
# 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 {}
for key, field in USER_FIELDS.items():
if isinstance(user.get(key), str):
metadata[field] = user[key]
metadata["user_fields"].append(field)
return metadata
def _file_signature(file_path):
try:
stat = os.stat(file_path)
return (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
except OSError:
return None
def _metadata_signature(file_path):
base_path = os.path.splitext(file_path)[0]
return (
_file_signature(file_path),
_file_signature(f"{base_path}.info"),
_file_signature(f"{base_path}.civitai.info"),
_file_signature(base_path + USER_INFO_SUFFIX),
)
@lru_cache(maxsize=4096)
def _get_metadata_cached(file_path, signature):
# ``signature`` is deliberately unused by the parser itself; it is part of
# the cache key and changes whenever the model or either metadata sidecar
# changes. The cache is bounded so removed/renamed models cannot grow it
# without limit.
return _read_metadata(file_path)
def get_metadata(file_path):
normalized_path = os.path.realpath(file_path)
signature = _metadata_signature(normalized_path)
# Metadata contains mutable lists. Never expose the cached object directly.
return copy.deepcopy(_get_metadata_cached(normalized_path, signature))
def clear_metadata_cache():
_get_metadata_cached.cache_clear()
def get_metadata_cache_info():
return _get_metadata_cached.cache_info()
+101 -112
View File
@@ -9,7 +9,7 @@ from aiohttp import web
import folder_paths
from .metadata import get_metadata
from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, is_model_file
from .model_media import _cache_token, _preview_url_for_model
from .folder_types import (
get_active_folder_types, get_active_model_roots,
@@ -32,7 +32,7 @@ def _collect_folder_models(target_dir, folder_type, path_idx, rel_subfolder, pag
return {"models": [], "total": 0, "page": page, "limit": limit}
valid_files = sorted(
(name for name in file_entries if name.endswith(('.safetensors', '.ckpt', '.pt'))),
(name for name in file_entries if is_model_file(name)),
key=str.lower,
)
total = len(valid_files)
@@ -123,7 +123,7 @@ def _collect_folders():
base_dir = item["base_dir"]
tree = {}
for root, dirs, files in os.walk(base_dir):
has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files)
has_models = any(is_model_file(f) for f in files)
rel = os.path.relpath(root, base_dir)
if rel == '.':
rel = '/'
@@ -133,7 +133,7 @@ def _collect_folders():
"path": rel,
"name": os.path.basename(root) if rel != '/' else '[Root]',
"has_models": has_models,
"model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt')))
"model_count": sum(1 for f in files if is_model_file(f))
}
label = item["bn"]
@@ -166,7 +166,7 @@ def _collect_folders():
tree = {}
for root, dirs, files in os.walk(base_dir):
has_models = any(f.endswith(('.safetensors', '.ckpt', '.pt')) for f in files)
has_models = any(is_model_file(f) for f in files)
rel = os.path.relpath(root, base_dir)
if rel == '.':
rel = '/'
@@ -176,7 +176,7 @@ def _collect_folders():
"path": rel,
"name": os.path.basename(root) if rel != '/' else '[Root]',
"has_models": has_models,
"model_count": sum(1 for f in files if f.endswith(('.safetensors', '.ckpt', '.pt')))
"model_count": sum(1 for f in files if is_model_file(f))
}
try:
@@ -253,7 +253,7 @@ def _iter_search_models():
continue
for root, _, files in os.walk(base_dir):
for filename in files:
if filename.lower().endswith(MODEL_EXTENSIONS + ('.sft',)):
if is_model_file(filename):
yield folder_type, path_idx, base_dir, os.path.join(root, filename)
@@ -289,116 +289,106 @@ async def api_find_model(request):
})
async def api_compatible_models(request):
base_model = request.query.get('base_model', '')
target_type = request.query.get('target_type', 'loras')
if not base_model:
return web.json_response({"models": []})
target_types = [t.strip() for t in target_type.split(',')]
compatible_models = []
# Prompt Notes ask for this base model to list models whose metadata names none.
UNLABELED_BASE_MODEL = "__unlabeled__"
def _normalised_base(value):
return str(value or "").strip().lower().replace(" ", "")
def _walk_model_files(target_types):
"""Yield (folder_type, path_idx, base_dir, root, filename, metadata) once per real file."""
seen_files = set()
for t in target_types:
for folder_type in target_types:
try:
paths = folder_paths.get_folder_paths(t)
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
if not paths:
continue
for path_idx, base_dir in enumerate(paths):
for path_idx, base_dir in enumerate(paths or []):
if not os.path.exists(base_dir):
continue
for root, _, files in os.walk(base_dir):
for f in files:
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
file_path = os.path.join(root, f)
real_path = os.path.realpath(file_path)
if real_path in seen_files:
continue
seen_files.add(real_path)
meta = get_metadata(file_path)
m_bm = str(meta.get("baseModel", "")).strip().lower().replace(" ", "")
req_bm = str(base_model).strip().lower().replace(" ", "")
if req_bm and m_bm and (req_bm in m_bm or m_bm in req_bm):
rel_subfolder = os.path.relpath(root, base_dir)
if rel_subfolder == '.':
rel_subfolder = '/'
else:
rel_subfolder = '/' + rel_subfolder.replace('\\', '/')
base_name = os.path.splitext(f)[0]
preview_file = None
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
if os.path.exists(os.path.join(root, base_name + ext)):
preview_file = base_name + ext
break
preview_url = ""
if preview_file:
q_type = urllib.parse.quote(t)
q_idx = str(path_idx)
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder != '/' else ""
q_file = urllib.parse.quote(preview_file)
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}"
try:
size_bytes = os.path.getsize(file_path)
size_mb = round(size_bytes / (1024 * 1024), 1)
except Exception:
size_mb = 0
size_bytes = 0
for filename in files:
if not is_model_file(filename):
continue
file_path = os.path.join(root, filename)
real_path = os.path.realpath(file_path)
if real_path in seen_files:
continue
seen_files.add(real_path)
yield folder_type, path_idx, base_dir, root, filename, get_metadata(file_path)
compatible_models.append({
"type": t,
"path_idx": path_idx,
"subfolder": rel_subfolder,
"filename": f,
"size_mb": size_mb,
"size_bytes": size_bytes,
"preview_url": preview_url,
"metadata": meta
})
return web.json_response({"models": compatible_models})
def _base_matches(requested, metadata):
model_base = _normalised_base(metadata.get("baseModel"))
if requested == UNLABELED_BASE_MODEL:
return not model_base
wanted = _normalised_base(requested)
return bool(wanted and model_base and (wanted in model_base or model_base in wanted))
def _compatible_models_sync(base_model, target_types):
compatible_models = []
for folder_type, path_idx, base_dir, root, filename, meta in _walk_model_files(target_types):
if not _base_matches(base_model, meta):
continue
rel_subfolder = os.path.relpath(root, base_dir)
rel_subfolder = '/' if rel_subfolder == '.' else '/' + rel_subfolder.replace('\\', '/')
base_name = os.path.splitext(filename)[0]
preview_file = None
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
if os.path.exists(os.path.join(root, base_name + ext)):
preview_file = base_name + ext
break
preview_url = ""
if preview_file:
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder != '/' else ""
preview_url = (
f"/anomalous/image?type={urllib.parse.quote(folder_type)}&path_idx={path_idx}"
f"&subfolder={q_sub}&filename={urllib.parse.quote(preview_file)}"
)
try:
size_bytes = os.path.getsize(os.path.join(root, filename))
except OSError:
size_bytes = 0
compatible_models.append({
"type": folder_type,
"path_idx": path_idx,
"subfolder": rel_subfolder,
"filename": filename,
"size_mb": round(size_bytes / (1024 * 1024), 1),
"size_bytes": size_bytes,
"preview_url": preview_url,
"metadata": meta,
})
return compatible_models
async def api_compatible_models(request):
base_model = request.query.get('base_model', '')
if not base_model:
return web.json_response({"models": []})
target_types = [t.strip() for t in request.query.get('target_type', 'loras').split(',') if t.strip()]
models = await asyncio.to_thread(_compatible_models_sync, base_model, target_types)
return web.json_response({"models": models})
def _base_models_sync(target_types):
base_models = set()
unlabeled = False
for *_, meta in _walk_model_files(target_types):
label = str(meta.get("baseModel") or "").strip()
if label:
base_models.add(label)
else:
unlabeled = True
return sorted(base_models), unlabeled
async def api_base_models(request):
target_types = get_active_folder_types()
base_models = set()
seen_files = set()
for t in target_types:
try:
paths = folder_paths.get_folder_paths(t)
except Exception:
continue
if not paths: continue
for base_dir in paths:
if not os.path.exists(base_dir): continue
for root, _, files in os.walk(base_dir):
for f in files:
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
file_path = os.path.join(root, f)
real_path = os.path.realpath(file_path)
if real_path in seen_files: continue
seen_files.add(real_path)
meta = get_metadata(file_path)
m_bm = meta.get("baseModel", "")
if m_bm and str(m_bm).strip():
# Remove typical generic strings that might pollute
clean_bm = str(m_bm).strip()
base_models.add(clean_bm)
return web.json_response({"base_models": sorted(list(base_models))})
base_models, unlabeled = await asyncio.to_thread(_base_models_sync, get_active_folder_types())
return web.json_response({"base_models": base_models, "unlabeled": unlabeled})
def _model_info_for_path(folder_type, path_idx, base_dir, file_path):
@@ -465,7 +455,7 @@ def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False
continue
except ValueError:
continue
if os.path.isfile(candidate) and candidate.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
if os.path.isfile(candidate) and is_model_file(candidate):
exact_results[original] = _model_info_for_path(folder_type, path_idx, base_dir, candidate)
unresolved = [(original, normalized) for original, normalized, _ in requested if original not in exact_results]
@@ -479,7 +469,7 @@ def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False
for folder_type, path_idx, base_dir in roots:
for root, _, files in os.walk(base_dir):
for filename in files:
if not filename.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
if not is_model_file(filename):
continue
rel_path = os.path.relpath(os.path.join(root, filename), base_dir).replace(os.sep, '/').lower()
basename = filename.lower()
@@ -552,7 +542,6 @@ async def api_resolve_paths_to_previews(request):
def _collect_all_scan_models(page, limit):
source_library_extensions = {'.safetensors', '.ckpt', '.pt', '.pth', '.bin', '.sft', '.gguf'}
all_tuples = []
for model_root in get_active_model_roots():
t = model_root["type"]
@@ -560,7 +549,7 @@ def _collect_all_scan_models(page, limit):
base_dir = model_root["base_dir"]
for root, dirs, files in os.walk(base_dir):
for f in files:
if os.path.splitext(f)[1].lower() in source_library_extensions:
if is_model_file(f):
all_tuples.append((t, path_idx, root, base_dir, f))
all_tuples.sort(key=lambda x: (x[0], x[4].lower()))
@@ -661,7 +650,7 @@ async def api_batch_select(request):
if not os.path.exists(base_dir): continue
for root, dirs, files in os.walk(base_dir):
for f in files:
if f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
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):
@@ -691,7 +680,7 @@ async def api_batch_select(request):
try: entries = os.listdir(target_dir)
except: entries = []
for f in entries:
if f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
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]
+10 -3
View File
@@ -1,16 +1,23 @@
"""Shared model catalog, media, and recovery constants."""
MEDIA_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi')
MODEL_EXTENSIONS = ('.safetensors', '.ckpt', '.pt', '.bin')
# Model files the browser lists, counts and looks up: GGUF and .pth too (quantized
# diffusion models, upscalers). Compared without case: see is_model_file().
MODEL_EXTENSIONS = ('.safetensors', '.ckpt', '.pt', '.pth', '.bin', '.sft', '.gguf')
PREVIEW_SUFFIXES = tuple(f'.preview{ext}' for ext in MEDIA_EXTENSIONS)
CIVITAI_BACKUP_SUFFIXES = tuple(f'.civitai_bak{ext}' for ext in MEDIA_EXTENSIONS)
SIDECAR_SUFFIXES = (
'.info', '.civitai.info', '.json', '.txt', '.yaml',
'.info', '.civitai.info', '.anomalous.json', '.json', '.txt', '.yaml',
*MEDIA_EXTENSIONS,
*PREVIEW_SUFFIXES,
*CIVITAI_BACKUP_SUFFIXES,
)
RESOLVABLE_MODEL_TYPES = (
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet',
'vae', 'vae_approx', 'clip', 'text_encoders', 'clip_vision',
'vae', 'vae_approx', 'clip', 'text_encoders', 'clip_vision', 'upscale_models',
)
def is_model_file(name):
"""Whether a file name is a model file (MODEL_EXTENSIONS, any case)."""
return name.lower().endswith(MODEL_EXTENSIONS)
+112
View File
@@ -0,0 +1,112 @@
"""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)."""
import os
import subprocess
import sys
import threading
import time
from aiohttp import web
from .utils import require_filename, resolve_folder_subdir, resolve_within
def _explorer_windows():
"""Handles of the open Explorer folder windows (window class CabinetWClass)."""
import ctypes
from ctypes import wintypes
found = []
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def collect(hwnd, _):
name = ctypes.create_unicode_buffer(64)
ctypes.windll.user32.GetClassNameW(hwnd, name, 64)
if name.value == "CabinetWClass":
found.append(hwnd)
return True
ctypes.windll.user32.EnumWindows(callback_type(collect), 0)
return found
def _window_title(hwnd):
import ctypes
title = ctypes.create_unicode_buffer(512)
ctypes.windll.user32.GetWindowTextW(hwnd, title, 512)
return title.value
def _to_front(hwnd):
"""Windows keeps a background program (ComfyUI here) from raising a window; borrowing the
foreground window's input queue for a moment lets it."""
import ctypes
user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32
foreground = user32.GetForegroundWindow()
theirs = user32.GetWindowThreadProcessId(foreground, None)
mine = kernel32.GetCurrentThreadId()
attached = theirs and theirs != mine and user32.AttachThreadInput(mine, theirs, True)
try:
user32.ShowWindow(hwnd, 9 if user32.IsIconic(hwnd) else 5) # SW_RESTORE / SW_SHOW
user32.BringWindowToTop(hwnd)
user32.SetForegroundWindow(hwnd)
finally:
if attached:
user32.AttachThreadInput(mine, theirs, False)
def _raise_explorer(folder, before):
"""Waits up to 3 s for the window showing `folder` (a new one, or the open one Explorer
reused) and brings it to the front."""
names = (os.path.basename(folder).lower(), os.path.normcase(folder).lower())
def shows_folder(hwnd):
# The folder's name (or full path), then " - File Explorer" in the system's language.
title = _window_title(hwnd).lower()
return any(title == name or title.startswith(name + " - ") for name in names)
for _ in range(30):
time.sleep(0.1)
windows = _explorer_windows()
fresh = [hwnd for hwnd in windows if hwnd not in before]
match = fresh or [hwnd for hwnd in windows if shows_folder(hwnd)]
if match:
_to_front(match[0])
return
def _reveal(path):
if sys.platform == "win32":
before = set(_explorer_windows())
subprocess.Popen(f'explorer /select,"{path}"')
threading.Thread(target=_raise_explorer, args=(os.path.dirname(path), before), daemon=True).start()
elif sys.platform == "darwin":
subprocess.Popen(["open", "-R", path])
else:
subprocess.Popen(["xdg-open", os.path.dirname(path)])
async def api_open_model_folder(request):
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
try:
filename = 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)
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)
try:
_reveal(path)
except OSError as error:
return web.json_response({"status": "error", "message": str(error)}, status=500)
return web.json_response({"status": "success"})
+8 -7
View File
@@ -9,6 +9,7 @@ from aiohttp import web
import folder_paths
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
from .trash import move_to_trash
from .utils import require_filename, resolve_folder_subdir, resolve_within
def _cache_token(file_path):
@@ -29,13 +30,13 @@ async def _handle_custom_cover(target_dir, filename, save_func, source_ext='.png
dest_path = os.path.join(target_dir, f"{base_name}{preview_ext}")
# Delete any existing .preview.* files to ensure only one custom cover is active
for ext in PREVIEW_SUFFIXES:
p = os.path.join(target_dir, f"{base_name}{ext}")
if os.path.exists(p) and p != dest_path:
try: os.remove(p)
except: pass
# Only one cover is active: the others go to the Recycle Bin (a failure keeps them).
older = [os.path.join(target_dir, f"{base_name}{ext}") for ext in PREVIEW_SUFFIXES]
try:
move_to_trash(*[path for path in older if path != dest_path])
except OSError as e:
print(f"[Anomalous Browser] Old cover kept: {e}")
await save_func(dest_path)
+48 -51
View File
@@ -14,6 +14,15 @@ from .model_constants import (
CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, MODEL_EXTENSIONS,
PREVIEW_SUFFIXES, SIDECAR_SUFFIXES,
)
from .activity_log import ACTIVITY_DETAIL
from .metadata import get_metadata
from .model_catalog import _resolve_paths_to_model_info_sync
from .trash import move_to_trash, trash_failure
from .utils import atomic_write_json
try:
from ..model_identity import USER_INFO_SUFFIX, read_json
except ImportError:
from model_identity import USER_INFO_SUFFIX, read_json
from .utils import require_filename, resolve_folder_subdir, resolve_within
def _first_existing_sidecar(base_path, suffixes):
@@ -43,9 +52,7 @@ def _reset_model_cover(base_path):
# remains untouched. os.replace then makes the actual restore atomic.
shutil.copy2(backup_path, temp_path)
os.replace(temp_path, restored_path)
for preview_path in preview_paths:
if preview_path != restored_path and os.path.isfile(preview_path):
os.remove(preview_path)
move_to_trash(*[path for path in preview_paths if path != restored_path])
except Exception as exc:
try:
if os.path.isfile(temp_path):
@@ -57,10 +64,9 @@ def _reset_model_cover(base_path):
if original_path:
try:
for preview_path in preview_paths:
os.remove(preview_path)
move_to_trash(*preview_paths)
except Exception as exc:
return False, 'restore_failed', str(exc)
return False, 'restore_failed', trash_failure(exc)
return True, 'original_cover', None
if preview_paths:
@@ -74,6 +80,11 @@ def _reset_model_cover(base_path):
async def api_delete_model(request):
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
try:
folder_type = data.get('type', 'checkpoints')
subfolder = data.get('subfolder', '/')
filename = data.get('filename', '')
@@ -91,37 +102,26 @@ async def api_delete_model(request):
if not os.path.exists(model_path):
return web.json_response({"status": "error", "message": "Model file not found"})
# 1. 优先尝试删除你点击的主模型文件
try:
os.remove(model_path)
except Exception as e:
error_msg = str(e)
if "being used" in error_msg or "WinError 32" in error_msg or "Permission" in error_msg:
error_msg = "文件被占用 (正在被 ComfyUI 使用)。请先重启 ComfyUI 或在工作流中卸载该模型后再删除!"
return web.json_response({"status": "error", "message": f"主模型删除失败: {error_msg}"})
base_name = os.path.splitext(filename)[0]
# 2. 主模型成功删除后,再清理配套的垃圾文件
# Sidecars are keyed by stem, not by the main model extension. If a
# second real model shares this stem, preserve the shared sidecars for
# the survivor instead of treating that model as cleanup debris.
shared_stem_in_use = any(
os.path.isfile(os.path.join(target_dir, base_name + model_ext))
for model_ext in MODEL_EXTENSIONS
for model_ext in MODEL_EXTENSIONS if base_name + model_ext != filename
)
deleted_files = [filename]
if not shared_stem_in_use:
for suffix in SIDECAR_SUFFIXES:
file_to_del = os.path.join(target_dir, base_name + suffix)
if os.path.isfile(file_to_del):
try:
os.remove(file_to_del)
deleted_files.append(base_name + suffix)
except Exception as e:
print(f"[Anomalous Browser] Warning: Failed to delete {file_to_del}: {e}")
# 3. 修正:前端期待的成功状态是 "success" 而不是 "ok"
sidecars = [] if shared_stem_in_use else [
os.path.join(target_dir, base_name + suffix) for suffix in SIDECAR_SUFFIXES
]
# The model and its covers and info go to the Recycle Bin together, or nothing moves.
try:
moved = move_to_trash(model_path, *sidecars)
except Exception as e:
return web.json_response({"status": "error", "message": trash_failure(e)})
deleted_files = [os.path.basename(path) for path in moved]
request[ACTIVITY_DETAIL] = {"files": deleted_files}
# 前端期待的成功状态是 "success" 而不是 "ok"
return web.json_response({
"status": "success",
"deleted": deleted_files,
@@ -191,28 +191,19 @@ async def api_update_metadata(request):
base_name = os.path.splitext(file_path)[0]
model_ext = os.path.splitext(file_path)[1]
info_file = f"{base_name}.civitai.info"
info_data = {}
if os.path.exists(info_file):
parsed = False
for enc in ['utf-8', 'utf-8-sig', 'mbcs', 'latin-1']:
try:
with open(info_file, 'r', encoding=enc) as f:
info_data = json.load(f)
parsed = True
break
except Exception:
pass
if not parsed:
return web.json_response({"status": "error", "message": "Failed to parse existing .civitai.info file due to encoding or corruption. Rename aborted to prevent data loss."})
# The user's edits have a file of their own (<model>.anomalous.json): a scan never
# writes it, and other tools that rewrite .civitai.info cannot lose them.
user_file = base_name + USER_INFO_SUFFIX
if os.path.exists(user_file) and read_json(user_file) is None:
return web.json_response({"status": "error", "message": f"{os.path.basename(user_file)} is unreadable; nothing was changed."})
user_data = read_json(user_file) or {"format": 1}
before = get_metadata(file_path) # what was shown, for the activity log
if 'custom_name' in data:
info_data["anomalous_custom_name"] = custom_name
user_data["custom_name"] = str(custom_name)
if 'custom_notes' in data:
info_data["anomalous_custom_notes"] = custom_notes
user_data["custom_notes"] = str(custom_notes)
if custom_source_url is not None:
info_data["anomalous_source_url"] = str(custom_source_url).strip()
user_data["source_url"] = str(custom_source_url).strip()
reset_cover = data.get('reset_cover', False)
cover_reset = None
@@ -221,8 +212,7 @@ async def api_update_metadata(request):
if reset_cover:
cover_reset, cover_reset_source, cover_reset_warning = _reset_model_cover(base_name)
with open(info_file, 'w', encoding='utf-8') as f:
json.dump(info_data, f, indent=4, ensure_ascii=False)
atomic_write_json(user_file, user_data)
new_filename = filename
@@ -245,6 +235,13 @@ async def api_update_metadata(request):
elif os.path.exists(new_file_path) and new_file_path != file_path:
return web.json_response({"status": "error", "message": "A file with the target physical name already exists."})
sent = [key for key in ("custom_name", "custom_notes") if key in data]
sent += ["source_url"] if custom_source_url is not None else []
fields = [{"field": key, "before": before.get(key) or "", "after": user_data[key]}
for key in sent if user_data[key] != (before.get(key) or "")]
if new_filename != filename:
fields.append({"field": "filename", "before": filename, "after": new_filename})
request[ACTIVITY_DETAIL] = {"fields": fields}
response_data = {
"status": "success",
"new_filename": new_filename,
+3 -6
View File
@@ -3,16 +3,15 @@
import asyncio
import json
import os
import struct
import threading
from aiohttp import web
import folder_paths
try:
from ..model_identity import computed_file_identity, normalise_sha256
from ..model_identity import computed_file_identity, file_sha256, infer_base_model_from_header, normalise_sha256
except ImportError:
from model_identity import computed_file_identity, normalise_sha256
from model_identity import computed_file_identity, file_sha256, infer_base_model_from_header, normalise_sha256
try:
from ..model_policies import requires_hash_for_model_recovery
except ImportError:
@@ -113,7 +112,6 @@ def _compute_and_save_fallback_info(file_path, file_hash):
with open(info_path, encoding='utf-8') as source:
info_data = json.load(source)
else:
from scraper import infer_base_model_from_header
filename = os.path.basename(file_path)
inferred_base = infer_base_model_from_header(file_path) if file_path.lower().endswith('.safetensors') else ""
if inferred_base == 'Unknown':
@@ -159,10 +157,9 @@ def _resolve_from_candidates(candidates, target_hash="", target_size=None, filen
# dynamically compute their hash on-demand to test against target_hash
unhashed_size_matches = [c for c in size_matches if not _candidate_hashes(c)]
if unhashed_size_matches:
from scraper import calculate_sha256
for candidate in unhashed_size_matches:
try:
computed_hash = calculate_sha256(candidate["path"]).upper()
computed_hash = file_sha256(candidate["path"]).upper()
candidate.setdefault("hashes", set()).add(computed_hash)
if computed_hash == target_hash:
_compute_and_save_fallback_info(candidate["path"], computed_hash)
+54
View File
@@ -0,0 +1,54 @@
"""One model type as a single list: a models folder and all its subfolders (the models page's type chips).
Listing a type also queues its covers' card images for the background (media_routes).
"""
import asyncio
import os
import urllib.parse
from aiohttp import web
import folder_paths
from .model_catalog import _collect_folder_models
from .media_routes import queue_card_images
def _collect_type_models(folder_type, path_idx):
"""Every model under one registered models folder, folder by folder in name order."""
try:
base_dir = folder_paths.get_folder_paths(folder_type)[path_idx]
except Exception:
return []
if not os.path.isdir(base_dir):
return []
models = []
for root, dirs, _files in os.walk(base_dir):
dirs.sort(key=str.lower)
rel = os.path.relpath(root, base_dir).replace('\\', '/')
models += _collect_folder_models(root, folder_type, path_idx, '' if rel == '.' else rel, 1, 0)["models"]
return models
async def api_get_type_models(request):
"""GET /anomalous/type_models?type&path_idx - {models, total}; each model names its own subfolder."""
try:
path_idx = int(request.query.get('path_idx', 0))
except ValueError:
path_idx = -1
if path_idx < 0:
return web.json_response({"status": "error", "message": "Invalid path_idx"}, status=400)
models = await asyncio.to_thread(_collect_type_models, request.query.get('type', ''), path_idx)
queue_card_images(_cover_paths(models))
return web.json_response({"models": models, "total": len(models)})
def _cover_paths(models):
"""The cover file of each listed model: it sits beside the model, named in its preview URL."""
paths = []
for model in models:
query = urllib.parse.urlparse(model.get("preview_url") or "").query
name = urllib.parse.parse_qs(query).get("filename", [""])[0]
if name and model.get("file_path"):
paths.append(os.path.join(os.path.dirname(model["file_path"]), name))
return paths
+65
View File
@@ -0,0 +1,65 @@
"""Saving one canvas node's current values as a parameter material (the current-node panel's "save")."""
import asyncio
import hashlib
import json
import time
import uuid
from aiohttp import web
from . import materials
from .material_schema import MAX_MATERIAL_NAME_LENGTH, _material_summary, _normalise_material_tags
from .notebooks import MAX_NOTEBOOK_BYTES
def _node_material(payload):
node = payload.get("node")
if not isinstance(node, dict):
raise ValueError("Invalid node")
node_type = node.get("type")
if not isinstance(node_type, str) or not node_type.strip() or len(node_type) > 200:
raise ValueError("Invalid node type")
values = node.get("widgets_values")
if not isinstance(values, list) or not values:
raise ValueError("Invalid node values")
encoded = json.dumps([node_type, values], ensure_ascii=False, allow_nan=False, sort_keys=True)
if len(encoded.encode("utf-8")) > MAX_NOTEBOOK_BYTES:
raise ValueError("Node values are too large")
title = node.get("title") if isinstance(node.get("title"), str) else ""
title = title.strip()[:200] or node_type
name = payload.get("name") or title
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material name")
return {
"schema_version": materials.MATERIAL_SCHEMA_VERSION,
"id": uuid.uuid4().hex,
"kind": "node_parameter_selection",
"name": name,
"tags": _normalise_material_tags(payload.get("tags", [])),
"timestamp": int(time.time() * 1000),
"source": {
"type": "canvas_node",
"parameter_name": title,
"parameter_signature": hashlib.sha256(encoded.encode("utf-8")).hexdigest(),
},
"workflow": {"nodes": [{"id": 1, "type": node_type, "title": title, "widgets_values": values}]},
"selection": {"scope": "nodes", "node_ids": [1]},
"capabilities": ["apply_node_parameters"],
}
async def api_save_node_material(request):
try:
material = _node_material(await request.json())
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid node material"}, status=400)
filename = f"material_{int(time.time())}_{material['id']}.json"
try:
duplicate = await asyncio.to_thread(
materials._persist_parameter_material, materials.get_materials_dir(), filename, material, False)
except (OSError, ValueError):
return web.json_response({"status": "error", "message": "Could not save node material"}, status=500)
if duplicate:
return web.json_response({"status": "duplicate", "filename": duplicate["filename"], "name": duplicate["name"]}, status=409)
return web.json_response({"status": "success", "filename": filename, "material": _material_summary(filename, material)})
+127 -123
View File
@@ -1,136 +1,140 @@
import asyncio
import hashlib
import json
import logging
import asyncio
import hashlib
import json
import logging
import os
import threading
from aiohttp import web
import folder_paths
from .utils import atomic_write_json, require_filename, resolve_within
MAX_NOTEBOOK_BYTES = 2 * 1024 * 1024
LEGACY_NOTEBOOKS_DIR = os.path.join(os.path.dirname(__file__), "notebooks")
_notebooks_lock = threading.RLock()
def _read_notebook(path):
if os.path.getsize(path) > MAX_NOTEBOOK_BYTES:
raise ValueError("Notebook is too large")
with open(path, encoding="utf-8") as source:
data = json.load(source)
if not isinstance(data, dict):
raise ValueError("Invalid notebook")
return data
def _migrate_notebooks(directory):
marker = resolve_within(directory, ".legacy_imported.json")
if os.path.exists(marker):
return
skipped = []
if os.path.isdir(LEGACY_NOTEBOOKS_DIR):
for filename in sorted(os.listdir(LEGACY_NOTEBOOKS_DIR)):
if not filename.endswith(".json") or filename.startswith("."):
continue
try:
source = resolve_within(LEGACY_NOTEBOOKS_DIR, require_filename(filename))
data = _read_notebook(source)
except (OSError, ValueError):
skipped.append(filename)
logging.warning("Anomalous: could not migrate notebook %s; original preserved", filename)
continue
target = resolve_within(directory, filename)
if os.path.exists(target):
try:
if _read_notebook(target) == data:
continue
except (OSError, ValueError):
pass
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:12]
recovered = f"{os.path.splitext(filename)[0][:100]}_legacy_{digest}.json"
target = resolve_within(directory, recovered)
if os.path.exists(target):
if _read_notebook(target) != data:
raise ValueError("Conflicting migrated notebook")
continue
atomic_write_json(target, data, MAX_NOTEBOOK_BYTES)
atomic_write_json(marker, {"version": 1, "skipped": skipped})
def get_notebooks_dir():
user_dir = folder_paths.get_user_directory()
directory = os.path.join(user_dir, "workflows", "anomalous_notebooks")
os.makedirs(directory, exist_ok=True)
with _notebooks_lock:
_migrate_notebooks(directory)
return directory
def _list_notebooks():
with _notebooks_lock:
directory = get_notebooks_dir()
notebooks = []
for filename in sorted(os.listdir(directory)):
if not filename.endswith(".json") or filename.startswith("."):
continue
try:
data = _read_notebook(resolve_within(directory, filename))
notebooks.append({"filename": filename, "name": data.get("name", filename[:-5]), "data": data})
except (OSError, ValueError):
continue
return notebooks
from .trash import move_to_trash
from .utils import atomic_write_json, require_filename, resolve_within
MAX_NOTEBOOK_BYTES = 2 * 1024 * 1024
LEGACY_NOTEBOOKS_DIR = os.path.join(os.path.dirname(__file__), "notebooks")
_notebooks_lock = threading.RLock()
def _read_notebook(path):
if os.path.getsize(path) > MAX_NOTEBOOK_BYTES:
raise ValueError("Notebook is too large")
with open(path, encoding="utf-8") as source:
data = json.load(source)
if not isinstance(data, dict):
raise ValueError("Invalid notebook")
return data
def _migrate_notebooks(directory):
marker = resolve_within(directory, ".legacy_imported.json")
if os.path.exists(marker):
return
skipped = []
if os.path.isdir(LEGACY_NOTEBOOKS_DIR):
for filename in sorted(os.listdir(LEGACY_NOTEBOOKS_DIR)):
if not filename.endswith(".json") or filename.startswith("."):
continue
try:
source = resolve_within(LEGACY_NOTEBOOKS_DIR, require_filename(filename))
data = _read_notebook(source)
except (OSError, ValueError):
skipped.append(filename)
logging.warning("Anomalous: could not migrate notebook %s; original preserved", filename)
continue
target = resolve_within(directory, filename)
if os.path.exists(target):
try:
if _read_notebook(target) == data:
continue
except (OSError, ValueError):
pass
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:12]
recovered = f"{os.path.splitext(filename)[0][:100]}_legacy_{digest}.json"
target = resolve_within(directory, recovered)
if os.path.exists(target):
if _read_notebook(target) != data:
raise ValueError("Conflicting migrated notebook")
continue
atomic_write_json(target, data, MAX_NOTEBOOK_BYTES)
atomic_write_json(marker, {"version": 1, "skipped": skipped})
def get_notebooks_dir():
user_dir = folder_paths.get_user_directory()
directory = os.path.join(user_dir, "workflows", "anomalous_notebooks")
os.makedirs(directory, exist_ok=True)
with _notebooks_lock:
_migrate_notebooks(directory)
return directory
def _list_notebooks():
with _notebooks_lock:
directory = get_notebooks_dir()
notebooks = []
for filename in sorted(os.listdir(directory)):
if not filename.endswith(".json") or filename.startswith("."):
continue
try:
data = _read_notebook(resolve_within(directory, filename))
notebooks.append({"filename": filename, "name": data.get("name", filename[:-5]), "data": data})
except (OSError, ValueError):
continue
return notebooks
async def api_get_notebooks(request):
try:
notebooks = await asyncio.to_thread(_list_notebooks)
return web.json_response({"notebooks": notebooks})
except (OSError, ValueError):
return web.json_response({"status": "error", "message": "Could not load notebooks"}, status=500)
def _save_notebook(filename, data):
with _notebooks_lock:
atomic_write_json(resolve_within(get_notebooks_dir(), filename), data, MAX_NOTEBOOK_BYTES)
try:
notebooks = await asyncio.to_thread(_list_notebooks)
return web.json_response({"notebooks": notebooks})
except (OSError, ValueError):
return web.json_response({"status": "error", "message": "Could not load notebooks"}, status=500)
def _save_notebook(filename, data):
with _notebooks_lock:
atomic_write_json(resolve_within(get_notebooks_dir(), filename), data, MAX_NOTEBOOK_BYTES)
async def api_save_notebook(request):
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if filename.startswith("."):
raise ValueError("Invalid notebook filename")
if not filename.endswith(".json"):
filename += ".json"
data = payload.get("data")
if not isinstance(data, dict):
raise ValueError("Invalid notebook")
await asyncio.to_thread(_save_notebook, filename, data)
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if filename.startswith("."):
raise ValueError("Invalid notebook filename")
if not filename.endswith(".json"):
filename += ".json"
data = payload.get("data")
if not isinstance(data, dict):
raise ValueError("Invalid notebook")
await asyncio.to_thread(_save_notebook, filename, data)
return web.json_response({"status": "success"})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not save notebook"}, status=500)
def _delete_notebook(filename):
with _notebooks_lock:
os.remove(resolve_within(get_notebooks_dir(), filename))
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not save notebook"}, status=500)
def _delete_notebook(filename):
with _notebooks_lock:
path = resolve_within(get_notebooks_dir(), filename)
if not os.path.isfile(path):
raise FileNotFoundError(path)
move_to_trash(path)
async def api_delete_notebook(request):
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json") or filename.startswith("."):
raise ValueError("Invalid notebook filename")
await asyncio.to_thread(_delete_notebook, filename)
return web.json_response({"status": "success"})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Notebook not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not delete notebook"}, status=500)
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json") or filename.startswith("."):
raise ValueError("Invalid notebook filename")
await asyncio.to_thread(_delete_notebook, filename)
return web.json_response({"status": "success"})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Notebook not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not delete notebook"}, status=500)
+2 -2
View File
@@ -12,6 +12,7 @@ from .recipe_constants import MAX_RECIPE_BYTES
from .recipe_images import _parameter_gallery_images
from .recipe_store import get_recipes_dir, _read_recipe
from .workflow_schema import _parameter_signature
from .trash import move_to_trash
def get_parameters_dir():
# Store parameter notebooks in the user directory
@@ -295,8 +296,7 @@ async def api_delete_parameter(request):
try:
parameters_dir = get_parameters_dir()
file_path = resolve_within(parameters_dir, filename)
if os.path.exists(file_path):
os.remove(file_path)
move_to_trash(file_path)
_invalidate_parameter_notebooks_cache()
except OSError:
return web.json_response({"status": "error", "message": "Could not delete parameter notebook"}, status=500)
+35
View File
@@ -6,6 +6,8 @@ import tempfile
import folder_paths
from .recipe_constants import MODEL_FILE_SUFFIXES
def resolve_within(base_dir, *parts):
"""Resolve a path and require it to stay inside base_dir, including through symlinks."""
@@ -19,6 +21,39 @@ def resolve_within(base_dir, *parts):
return candidate
def _model_roots():
for folder_type in getattr(folder_paths, "folder_names_and_paths", {}):
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_index, base_dir in enumerate(paths or []):
if os.path.isdir(base_dir):
yield folder_type, path_index, os.path.realpath(base_dir)
def _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
except ValueError:
continue
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
continue
return {
"path": candidate,
"folder_type": folder_type,
"path_index": path_index,
}
return None
def get_folder_root(folder_type, path_idx=0):
paths = folder_paths.get_folder_paths(folder_type)
if not paths or not isinstance(path_idx, int) or path_idx < 0 or path_idx >= len(paths):
+2
View File
@@ -1,6 +1,7 @@
"""Contained Recipe image assets and bounded output-gallery inspection."""
from io import BytesIO
import hashlib
import heapq
import json
import os
@@ -8,6 +9,7 @@ import os
import folder_paths
from PIL import Image
from .path_utils import _resolve_exact_model_reference
from .recipe_constants import *
from .utils import require_filename, resolve_within
from .workflow_schema import _node_type, _parameter_signature, _workflow_node_signature
+20 -35
View File
@@ -10,6 +10,7 @@ import folder_paths
from .metadata import get_metadata
from .recipe_constants import *
from .path_utils import _model_roots, _resolve_exact_model_reference
from .recipe_images import _attach_preview_snapshots
from .utils import require_filename, resolve_within
from .workflow_schema import (
@@ -17,13 +18,28 @@ from .workflow_schema import (
_widget_values, _workflow_fingerprint,
)
# All-in-one loaders with a verified serialized widget layout. Must stay in sync
# with ALL_IN_ONE_LOADER_SPECS in web/modules/recipe_identity.js.
ALL_IN_ONE_LOADER_SPECS = {
# ComfyUI-Easy-Use: ckpt_name, vae_name, clip_skip, lora_name, ...
"easy a1111loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")),
# ComfyUI-Easy-Use: ckpt_name, config_name, vae_name, clip_skip, lora_name, ...
"easy fullloader": ((0, "checkpoint", "ckpt_name"), (2, "vae", "vae_name"), (4, "lora", "lora_name")),
# efficiency-nodes-comfyui: ckpt_name, vae_name, clip_skip, lora_name, ...
"efficient loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")),
}
PLACEHOLDER_MODEL_VALUES = {"none", "baked vae"}
def _model_reference_specs(node):
"""Known loader adapters only; arbitrary third-party widgets stay parameters."""
node_type = _node_type(node)
lowered = node_type.lower()
lowered = node_type.lower().strip()
specs = []
if re.search(r"checkpointloader(simple)?$", lowered):
if lowered in ALL_IN_ONE_LOADER_SPECS:
specs.extend(ALL_IN_ONE_LOADER_SPECS[lowered])
elif re.search(r"checkpointloader(simple)?$", lowered):
specs.append((0, "checkpoint", "checkpoint"))
elif lowered.endswith("unetloader"):
specs.append((0, "unet", "unet"))
@@ -44,39 +60,6 @@ def _model_reference_specs(node):
return specs
def _model_roots():
for folder_type in getattr(folder_paths, "folder_names_and_paths", {}):
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_index, base_dir in enumerate(paths or []):
if os.path.isdir(base_dir):
yield folder_type, path_index, os.path.realpath(base_dir)
def _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
except ValueError:
continue
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
continue
return {
"path": candidate,
"folder_type": folder_type,
"path_index": path_index,
}
return None
def _identity_for_reference(saved_value):
resolved = _resolve_exact_model_reference(saved_value)
if not resolved:
@@ -175,6 +158,8 @@ def _build_model_references(recipe, verify_identities=False):
saved_value = values[widget_index] if widget_index < len(values) else None
if not isinstance(saved_value, str) or not saved_value.strip():
continue
if saved_value.strip().lower() in PLACEHOLDER_MODEL_VALUES:
continue
identity_result = _identity_for_reference(saved_value)
if isinstance(identity_result, tuple):
identity, origin = (identity_result + (None,))[:2]
+6 -8
View File
@@ -11,6 +11,7 @@ import folder_paths
from .recipe_constants import MAX_HISTORY_VERSIONS
from .recipe_images import _recipe_assets_dir
from .trash import move_to_trash
from .utils import require_filename, resolve_within
def get_recipes_dir():
@@ -131,11 +132,8 @@ def _list_recipe_history(recipes_dir, filename):
def _delete_recipe_with_history(recipes_dir, filename):
"""Delete an explicitly selected recipe and its contained local history."""
os.remove(resolve_within(recipes_dir, filename))
history_dir = _history_dir(recipes_dir, filename)
if os.path.isdir(history_dir):
shutil.rmtree(history_dir)
assets_dir = _recipe_assets_dir(recipes_dir, filename)
if os.path.isdir(assets_dir):
shutil.rmtree(assets_dir)
"""Moves an explicitly selected recipe, its history and its images to the Recycle Bin."""
path = resolve_within(recipes_dir, filename)
if not os.path.isfile(path):
raise FileNotFoundError(path)
move_to_trash(path, _history_dir(recipes_dir, filename), _recipe_assets_dir(recipes_dir, filename))
+166
View File
@@ -0,0 +1,166 @@
"""What a scan did, model by model.
The scraper appends one JSON line per model it changed to a report file (--report-file),
plus events: "civitai_down" when Civitai stopped answering, "done" with the count of models
left as they were. When a scan ends, ScanJob turns the lines of all its folders into one
result: kept as the last scan (GET /anomalous/last_scan, shown on the scan page) and recorded
in the activity log, with each model located by type, path index and relative path so the
browser can open it (GET /anomalous/scan_model).
"""
import asyncio
import json
import os
import time
from aiohttp import web
import folder_paths
from .activity_log import MAX_CHANGES, add_entry, log_path
from .folder_types import get_active_folder_types
from .model_catalog import _model_info_for_path
from .path_utils import atomic_write_json, resolve_folder_subdir
MAX_FILES = 2000 # models kept in the last scan's result
FILE_FIELDS = ("status", "reason", "name", "base", "cover", "error")
def last_scan_path():
return os.path.join(os.path.dirname(log_path()), "last_scan.json")
def locate_root(directory):
"""(type, path_idx) of the models folder that is `directory`, shown types first; None if none."""
target = os.path.normcase(os.path.realpath(directory))
active = get_active_folder_types()
types = active + [name for name in folder_paths.folder_names_and_paths if name not in active]
for folder_type in types:
try:
paths = folder_paths.get_folder_paths(folder_type) or []
except Exception:
continue
for path_idx, path in enumerate(paths):
if os.path.normcase(os.path.realpath(path)) == target:
return folder_type, path_idx
return None
def read_report(path):
"""(model entries, events) from a report file; both empty when there is none."""
entries, events = [], []
try:
with open(path, encoding="utf-8") as source:
for line in source:
try:
item = json.loads(line)
except ValueError:
continue
if isinstance(item, dict):
(events if "event" in item else entries).append(item)
except OSError:
pass
return entries, events
class ScanJob:
"""One scan from start to end: what to call it, its options, and its folders' reports."""
def __init__(self, kind, data):
self.kind = kind # "all" | "picked" | "one"
self.options = {key: bool(data.get(key)) for key in ("offline_only", "retry_unmatched", "force_overwrite")}
self.started = time.time()
self.files = []
self.unchanged = 0
self.civitai_down = False
self.errors = []
def add_folder(self, report_file, base_dir, located=None):
"""Reads (and removes) one scraper run's report. `located`: (type, path_idx) of base_dir."""
located = located or locate_root(base_dir) or ("", 0)
entries, events = read_report(report_file)
try:
os.remove(report_file)
except OSError:
pass
for event in events:
if event.get("event") == "done":
self.unchanged += int(event.get("unchanged") or 0)
elif event.get("event") == "civitai_down":
self.civitai_down = True
for entry in entries:
path = entry.get("renamed_to") if entry.get("renamed_to") and entry.get("status") != "failed" else entry.get("file")
if not isinstance(path, str):
continue
item = {key: entry[key] for key in FILE_FIELDS if entry.get(key)}
item.update(type=located[0], path_idx=located[1], filename=os.path.basename(path),
rel=os.path.relpath(path, base_dir).replace(os.sep, "/"))
if entry.get("renamed_to") and item["status"] != "failed":
item["renamed_from"] = os.path.basename(entry.get("file") or "")
if entry.get("duplicate_of"):
item["duplicate"] = True
self.files.append(item)
def add_error(self, message):
self.errors.append(str(message)[:300])
def result(self):
files = self.files
counts = {
"matched": sum(item["status"] == "matched" for item in files),
"inferred": sum(item["status"] == "inferred" for item in files),
"failed": sum(item["status"] == "failed" for item in files),
"covers": sum(item.get("cover") == "new" for item in files),
"renamed": sum(bool(item.get("renamed_from")) for item in files),
"unchanged": self.unchanged,
}
return {
"kind": self.kind, "options": self.options, "started": self.started,
"finished": time.time(), "counts": counts, "civitai_down": self.civitai_down,
"errors": self.errors[:10], "files": files[:MAX_FILES],
}
def finish(self):
"""Keeps the result as the last scan and records it in the activity log. Never raises:
the scan itself is done, the record of it is a convenience."""
result = self.result()
try:
atomic_write_json(last_scan_path(), result)
except Exception as error: # noqa: BLE001
print(f"[Anomalous Browser] Could not keep the scan result: {error}")
try:
target = result["files"][0]["filename"] if self.kind == "one" and result["files"] else ""
detail = {"scan": {key: result[key] for key in ("kind", "options", "counts", "civitai_down", "errors")},
"files": result["files"][:MAX_CHANGES], "total": len(self.files),
"seconds": round(result["finished"] - result["started"])}
add_entry("file", "scan_done", target, detail)
except Exception as error: # noqa: BLE001
print(f"[Anomalous Browser] Could not record the scan in the activity log: {error}")
return result
async def api_last_scan(request):
"""GET /anomalous/last_scan - the last scan's result, or {} before the first one."""
def read():
try:
with open(last_scan_path(), encoding="utf-8") as source:
data = json.load(source)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
return web.json_response(await asyncio.to_thread(read))
async def api_scan_model(request):
"""GET /anomalous/scan_model?type&path_idx&rel - one model as the grid lists it, to open it."""
rel = request.query.get("rel", "").replace("\\", "/").strip("/")
subfolder, filename = os.path.split(rel)
try:
path_idx = int(request.query.get("path_idx", 0))
base_dir, target_dir = resolve_folder_subdir(request.query.get("type", ""), path_idx, "/" + subfolder)
except (ValueError, KeyError, TypeError):
return web.json_response({"error": "Invalid model location"}, status=400)
file_path = os.path.join(target_dir, filename)
if not filename or filename in (".", "..") or not os.path.isfile(file_path):
return web.json_response({"error": "Model not found"}, status=404)
model = await asyncio.to_thread(_model_info_for_path, request.query.get("type"), path_idx, base_dir, file_path)
return web.json_response({"model": model})
+71
View File
@@ -0,0 +1,71 @@
"""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."""
import asyncio
import os
from aiohttp import web
from .folder_types import get_active_scan_paths
from .scan_report import locate_root
try:
from ..model_identity import is_unmatched, sidecar_info, unmatched_reason
except ImportError: # loaded outside the package (tests)
from model_identity import is_unmatched, sidecar_info, unmatched_reason
MAX_LISTED = 2000 # models listed per kind; the counts stay exact
# Model files in other formats: listed as "not scanned" (scans read only .safetensors).
OTHER_MODEL_EXTENSIONS = (".ckpt", ".pt", ".pth", ".bin", ".gguf", ".onnx", ".sft", ".engine")
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)."""
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):
hidden = any(part.startswith(".") for part in os.path.relpath(root, base_dir).split(os.sep) if part != ".")
for filename in files:
other = filename.lower().endswith(OTHER_MODEL_EXTENSIONS)
if not filename.endswith(".safetensors") and not (other and not hidden):
continue
path = os.path.join(root, filename)
real = os.path.realpath(path)
if real in seen:
continue
seen.add(real)
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)
return {
"total": total, "matched": matched, "unmatched": len(unmatched), "new": len(new),
"pending": sum(item["reason"] in ("offline", "network") for item in unmatched),
"unmatched_models": unmatched[:MAX_LISTED], "new_models": new[:MAX_LISTED],
"skipped": len(skipped), "skipped_models": skipped[:MAX_LISTED],
}
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))
+423 -545
View File
File diff suppressed because it is too large Load Diff
+19 -5
View File
@@ -9,9 +9,17 @@ import urllib.request
from aiohttp import web
_DEEPL_TARGETS = {
"zh": "ZH", "zh-CN": "ZH", "zh-TW": "ZH-HANT", "en": "EN", "ja": "JA", "ko": "KO",
"fr": "FR", "de": "DE", "es": "ES", "ru": "RU", "pt": "PT-BR",
}
def _translate_with_deepl(text, tl, deepl_key):
deepl_map = { "zh-CN": "ZH", "en": "EN", "ja": "JA", "ko": "KO", "fr": "FR", "de": "DE", "es": "ES", "ru": "RU" }
d_tl = deepl_map.get(tl, "EN")
"""None for a target DeepL does not take: the other providers are asked instead."""
d_tl = _DEEPL_TARGETS.get(tl)
if not d_tl:
return None
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,
@@ -50,8 +58,9 @@ def _translate_with_google(text, tl):
def _translate_with_mymemory(text, tl):
has_cn = any('\u4e00' <= char <= '\u9fa5' for char in text)
sl = 'zh-CN' if has_cn else 'en'
pair_tl = 'en' if has_cn else ('zh-CN' if tl in ('zh', 'zh-CN') else tl)
# Text that is not plain English and not Chinese (kana, hangul, accents...) is left to its detection.
sl = 'zh-CN' if has_cn else ('en' if text.isascii() else 'Autodetect')
pair_tl = 'zh-CN' if tl in ('zh', 'zh-CN') else tl
url = f"https://api.mymemory.translated.net/get?q={urllib.parse.quote(text)}&langpair={sl}|{pair_tl}"
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
@@ -73,7 +82,12 @@ def _translate_with_mymemory(text, tl):
async def api_translate(request):
try:
data = await request.json()
text = str(data.get("text", "")).strip()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
text = str(data.get("text", "")).strip()
try:
tl = str(data.get("target_lang", "zh-CN")).strip()
if not text:
return web.json_response({"translated": "", "status": "success"})
+19
View File
@@ -0,0 +1,19 @@
"""The API's way to delete the user's files: recycle_bin.py (shared with the scanner)."""
try:
from ..recycle_bin import TrashUnavailable, move_to_trash
except ImportError: # loaded outside the package (tests)
from recycle_bin import TrashUnavailable, move_to_trash
__all__ = ["TrashUnavailable", "move_to_trash", "trash_failure"]
def trash_failure(exc):
"""What to tell the user when moving to the Recycle Bin did not happen."""
if isinstance(exc, TrashUnavailable):
return ("这个位置没有回收站(U 盘或网络盘),为了安全没有删除,请在文件管理器里手动删除。"
" / No Recycle Bin here (USB or network drive); nothing was deleted, please delete it in your file manager.")
if getattr(exc, "errno", None) == 32 or getattr(exc, "winerror", None) == 32 or "being used" in str(exc):
return ("文件正在被使用(可能 ComfyUI 已经加载了它),没有删除。请先卸载模型或重启 ComfyUI。"
" / The file is in use (ComfyUI may have it loaded); nothing was deleted.")
return f"没有删除 / Not deleted: {exc}"
+364
View File
@@ -0,0 +1,364 @@
"""Plugin version display, published-release listing and version switching.
The plugin is normally installed as a git clone (ComfyUI Manager), so the
installed version is read from git and switching checks out a published
release tag. Nothing runs automatically: every network call starts from a
button in the UI. Switching refuses when tracked files have local changes, and
only tags that are published releases (or at least exist on the remote) can be
checked out.
"""
import asyncio
import json
import os
import re
import shutil
import subprocess
import threading
import time
import urllib.request
from aiohttp import web
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATE_FILE = os.path.join(PLUGIN_DIR, ".anomalous_version.local.json")
# A version whose tree contains this file has the version panel; older ones do not.
SWITCHER_MARKER = "api/version_manager.py"
TAG_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$")
GITHUB_REMOTE = re.compile(r"github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$", re.IGNORECASE)
RELEASES_CACHE_SECONDS = 300
NETWORK_TIMEOUT = 10
FETCH_TIMEOUT = 180
_lock = threading.Lock()
_releases_cache = {"expires": 0, "data": None}
class VersionError(Exception):
"""A user-facing failure with a stable code for the UI."""
def __init__(self, code, message="", **details):
super().__init__(message or code)
self.code = code
self.details = details
# ---------- git helpers ----------
def _git_executable():
found = shutil.which("git")
if not found:
raise VersionError("git_missing", "git executable not found")
return found
def _git(*args, timeout=30, check=True):
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],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout, env=env, **kwargs,
)
except subprocess.TimeoutExpired:
raise VersionError("git_timeout", f"git {' '.join(args[:2])} timed out")
if check and result.returncode != 0:
raise VersionError("git_failed", (result.stderr or result.stdout).strip()[-500:])
return result
def _git_out(*args, **kwargs):
result = _git(*args, check=False, **kwargs)
return result.stdout.strip() if result.returncode == 0 else ""
def version_key(tag):
"""Sortable key for tags such as v1.4.2, v1.57-beta, v1.58.0-rc1 (stable after pre-release)."""
match = re.match(r"^v?(\d+(?:\.\d+)*)(.*)$", str(tag or ""))
if not match:
return ((), 0, str(tag))
numbers = tuple(int(part) for part in match.group(1).split("."))
suffix = match.group(2).lstrip("-_.")
return (numbers, 1 if not suffix else 0, suffix)
def current_state():
"""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":
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
# Porcelain lines are "XY path"; keep the leading status column (no strip before slicing).
status = _git("status", "--porcelain", "--untracked-files=no", check=False).stdout
changed = [line[3:] for line in status.splitlines() if line.strip()]
return {
"is_git": True,
"commit": commit[:12],
"branch": branch,
"tag": exact_tag,
"base_tag": base_tag,
"label": exact_tag or described or commit[:12],
"dirty": bool(changed),
"changed_files": changed[:20],
"remote": _git_out("remote", "get-url", "origin") or None,
"previous": _read_state().get("previous"),
}
# ---------- releases ----------
def _github_repo(remote_url):
match = GITHUB_REMOTE.search(remote_url or "")
return (match.group(1), match.group(2)) if match else None
def _fetch_github_releases(owner, repo):
url = f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=50"
request = urllib.request.Request(url, headers={
"Accept": "application/vnd.github+json",
"User-Agent": "Anomalous-Model-Browser",
})
with urllib.request.urlopen(request, timeout=NETWORK_TIMEOUT) as response:
payload = json.loads(response.read().decode("utf-8"))
releases = []
for item in payload if isinstance(payload, list) else []:
if item.get("draft") or not item.get("tag_name"):
continue
releases.append({
"tag": item["tag_name"],
"name": item.get("name") or item["tag_name"],
"published_at": item.get("published_at"),
"prerelease": bool(item.get("prerelease")),
"notes": (item.get("body") or "")[:8000],
"url": item.get("html_url"),
})
return releases
def _remote_tags():
output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3)
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):
"""Published releases, newest first. GitHub API first, `git ls-remote` (uses git's proxy) as fallback."""
state = current_state()
if not state.get("is_git"):
raise VersionError("not_git", "The plugin is not a git checkout")
now = time.monotonic()
if not force and _releases_cache["data"] and now < _releases_cache["expires"]:
releases, source = _releases_cache["data"]
else:
releases, source = None, None
repo = _github_repo(state.get("remote"))
if repo:
try:
releases, source = _fetch_github_releases(*repo), "github"
except Exception:
releases = None
if releases is None:
releases, source = _remote_tags(), "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))
current_key = version_key(state.get("tag") or state.get("base_tag"))
stable = [item for item in releases if not item["prerelease"]]
latest = stable[0]["tag"] if stable else None
result = []
for item in releases:
key = version_key(item["tag"])
entry = dict(item)
entry["current"] = item["tag"] == state.get("tag")
entry["latest"] = item["tag"] == latest
entry["direction"] = "same" if entry["current"] else ("newer" if key > current_key else "older")
result.append(entry)
return {"state": state, "releases": result, "latest": latest, "source": source}
# ---------- switching ----------
def _read_state():
try:
with open(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"
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
os.replace(temporary, STATE_FILE)
def _require_switchable():
state = current_state()
if not state.get("is_git"):
raise VersionError("not_git", "The plugin is not a git checkout")
if state["dirty"]:
raise VersionError("dirty", "Tracked files have local changes", files=state["changed_files"])
return state
def _known_tag(tag):
if not TAG_NAME.match(str(tag or "")):
raise VersionError("bad_tag", "Invalid version name")
tags = {item["tag"] for item in list_releases()["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 preview_switch(tag):
"""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
target_key = version_key(tag)
current_key = version_key(state.get("tag") or state.get("base_tag"))
return {
"tag": tag,
"direction": "same" if tag == state.get("tag") else ("newer" if target_key > current_key else "older"),
"has_switcher": has_switcher,
"from": state["label"],
"from_branch": state["branch"],
}
def switch_to(tag):
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}
def _default_branch():
ref = _git_out("symbolic-ref", "--short", "refs/remotes/origin/HEAD")
if ref.startswith("origin/"):
return ref.split("/", 1)[1]
for name in ("main", "master"):
if _git("ls-remote", "--exit-code", "--heads", "origin", name, check=False, timeout=NETWORK_TIMEOUT * 3).returncode == 0:
return name
raise VersionError("no_default_branch", "Could not determine the default branch")
def return_to_latest():
"""Back to the default branch at its newest commit; the same path ComfyUI Manager's update takes."""
with _lock:
state = _require_switchable()
branch = _default_branch()
_git("fetch", "--no-tags", "origin", f"+refs/heads/{branch}:refs/remotes/origin/{branch}", timeout=FETCH_TIMEOUT)
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
if _git("rev-parse", "--verify", "--quiet", f"refs/heads/{branch}", check=False).returncode == 0:
_git("checkout", "--quiet", branch)
else:
_git("checkout", "--quiet", "-b", branch, f"origin/{branch}")
merge = _git("merge", "--ff-only", "--quiet", f"origin/{branch}", check=False)
if merge.returncode != 0:
raise VersionError("diverged", f"Local {branch} has commits that are not on the remote")
_write_state({"previous": previous, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
def undo_last_switch():
with _lock:
previous = _read_state().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"]}
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"])
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}
# ---------- routes ----------
def _error_response(error):
status = 409 if error.code in {"dirty", "diverged"} else 400
if error.code in {"offline", "git_timeout", "git_failed"}:
status = 502
return web.json_response({"success": False, "code": error.code, "error": str(error), **error.details}, status=status)
async def _run(handler, *args):
try:
return web.json_response({"success": True, **(await asyncio.to_thread(handler, *args))})
except VersionError as error:
return _error_response(error)
async def _json_body(request):
try:
data = await request.json()
except Exception:
return {}
return data if isinstance(data, dict) else {}
async def api_get_version(request):
"""GET /anomalous/version - Installed version (local, no network)."""
try:
return web.json_response({"success": True, "state": await asyncio.to_thread(current_state)})
except VersionError as error:
return _error_response(error)
async def api_get_releases(request):
"""GET /anomalous/version/releases?refresh=1 - Published releases (network, only when asked)."""
return await _run(list_releases, request.query.get("refresh") == "1")
async def api_preview_switch(request):
"""POST /anomalous/version/preview {tag}"""
return await _run(preview_switch, str((await _json_body(request)).get("tag", "")))
async def api_switch_version(request):
"""POST /anomalous/version/switch {tag}"""
return await _run(switch_to, str((await _json_body(request)).get("tag", "")))
async def api_return_to_latest(request):
"""POST /anomalous/version/latest"""
return await _run(return_to_latest)
async def api_undo_switch(request):
"""POST /anomalous/version/undo"""
return await _run(undo_last_switch)
def register_routes(app):
app.router.add_get('/anomalous/version', api_get_version)
app.router.add_get('/anomalous/version/releases', api_get_releases)
app.router.add_post('/anomalous/version/preview', api_preview_switch)
app.router.add_post('/anomalous/version/switch', api_switch_version)
app.router.add_post('/anomalous/version/latest', api_return_to_latest)
app.router.add_post('/anomalous/version/undo', api_undo_switch)
+107
View File
@@ -0,0 +1,107 @@
"""Civitai over the network, for the scanner: a model version by file hash, a model page's
description, and preview media. The API key comes from the plugin's config.json.
fetch_civitai_info tells the two failures apart: None means Civitai answered that it does not
know the file; CivitaiUnreachable means it could not be asked (no connection, timeouts, errors).
"""
import json
import os
import time
import urllib.error
import urllib.request
from typing import Dict, Optional
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"
class CivitaiUnreachable(Exception):
"""Civitai did not answer (network, timeout or server error) after the retries."""
def _load_api_key():
plugin_dir = os.path.dirname(os.path.abspath(__file__))
for config_path in (os.path.join(plugin_dir, "api", "config.json"), os.path.join(plugin_dir, "config.json")):
if not os.path.exists(config_path):
continue
try:
with open(config_path, 'r', encoding='utf-8') as f:
key = json.load(f).get("CIVITAI_API_KEY", "")
if isinstance(key, str) and key.strip():
return key.strip()
except Exception as e:
print(f"[-] 读取 config.json 失败: {e}")
return None
CIVITAI_API_KEY = _load_api_key()
def _headers():
headers = {"User-Agent": USER_AGENT}
if CIVITAI_API_KEY:
headers["Authorization"] = f"Bearer {CIVITAI_API_KEY}"
return headers
def fetch_civitai_info(file_hash: str, max_retries: int = 3) -> Optional[Dict]:
"""Civitai's record of the model version with this file hash; None when Civitai does not
know the file (404). Raises CivitaiUnreachable when it could not be asked."""
url = f"https://civitai.com/api/v1/model-versions/by-hash/{file_hash}"
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})")
if attempt < max_retries - 1:
time.sleep(2)
print(f"\033[93m[Skip] 模型 Hash {file_hash} 网络重试失败。\033[0m")
raise CivitaiUnreachable(file_hash)
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:
return json.loads(response.read().decode('utf-8')).get("description") or ""
except Exception as e:
print(f"[-] 获取模型主页详细说明失败: {e}")
return ""
def download_media(url: str, base_path: str, max_retries: int = 3):
"""Downloads an image or video to base_path + the extension its type calls for; the path, or None."""
for attempt in range(max_retries):
try:
with urllib.request.urlopen(urllib.request.Request(url, headers=_headers()), timeout=15) as response:
content_type = response.headers.get("Content-Type", "").lower()
ext = ".png"
if "video/mp4" in content_type: ext = ".mp4"
elif "video/webm" in content_type: ext = ".webm"
elif "image/jpeg" in content_type: ext = ".jpg"
elif "image/webp" in content_type: ext = ".webp"
elif url.endswith(".mp4"): ext = ".mp4"
final_path = base_path + ext
with open(final_path, 'wb') as f:
while True:
chunk = response.read(8192)
if not chunk:
break
f.write(chunk)
return final_path
except urllib.error.HTTPError as e:
print(f"[-] 媒体下载失败,状态码: {e.code} (尝试 {attempt+1}/{max_retries})")
except urllib.error.URLError as e:
print(f"[-] 媒体下载网络异常: {e.reason} (尝试 {attempt+1}/{max_retries})")
except Exception:
pass
return None
+4 -4
View File
@@ -9,12 +9,12 @@ open only the topic needed for the current task.
media lifecycle, panel state, and graph mutations.
- [`recipes.md`](recipes.md): Workflow Recipes, packages, galleries, Parameter
Notebooks, prompt roles, and recipe-to-model transitions.
- [`material-library.md`](material-library.md): generated-image snapshots,
material assets, node-block reuse, and Model Doctor handoff.
- [`material-library.md`](material-library.md): saved prompts and node values
(material files), keeping an output image, and dragging prompts onto the canvas.
- [`recipe-model-lifecycle.md`](recipe-model-lifecycle.md): authoritative model
fields, Hash synchronization, local matching, partial append, and note privacy.
- [`model-resolution.md`](model-resolution.md): model provenance, Model Doctor,
hash injection, deep scanning, and recovery policy.
- [`model-resolution.md`](model-resolution.md): model provenance, Model Check,
hash injection, model scanning, and recovery policy.
- [`../decisions/README.md`](../decisions/README.md): concise records of product
and architectural decisions that still explain current behavior.
+60 -10
View File
@@ -40,6 +40,20 @@ inventory recognizes GGUF and PTH case-insensitively in addition to the existing
list formats. This route-level inventory does not expand global model-format,
scanner, metadata-parser, recovery, or cloud-identification support.
## Requests from other devices
ComfyUI started with `--listen` lets other devices on the network open the page. They may
look; every change under `/anomalous/` answers only the computer ComfyUI runs on. One
middleware (`api/local_guard.py`, registered first in `setup_routes`) decides it for all routes:
a request other than GET must be local unless its path is in `OPEN_POSTS` (reads sent as POST:
`resolve_paths_to_previews`, `resolve_hash_batch`, `inspect_image_material`); a GET is open
unless its path is in `LOCAL_GETS` (`backup/file`, `version/releases`, `import/folders`).
`/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.
## Storage ownership
Runtime UI settings and newly stored API keys live in `api/config.json`.
@@ -59,19 +73,32 @@ persistence/history live in `workflow_schema.py`, `recipe_schema.py`,
`recipe_images.py`, and `recipe_store.py`; `recipes.py` is the HTTP facade.
Material shaping, private assets, and the single persistence lock/summary cache
live in `material_schema.py`, `material_assets.py`, and `material_store.py`;
`materials.py` owns request/response mapping and compatibility entry points.
`materials.py` owns request/response mapping and compatibility entry points;
`node_material.py` saves one canvas node's values as a `node_parameter_selection`
material (deduplicated by node type and values like other parameter materials).
The former mixed utility routes are separated: `media_routes.py` owns card
thumbnails and model/output media lookups, `gallery_routes.py` owns the bounded
output snapshot and deletion (searched through `image_search.py`, which reads
PNG text chunks only and caches per-image records by mtime), `translation_routes.py`
owns provider fallback, and `folder_types.py` owns configured visibility and scan scope.
owns provider fallback, `folder_types.py` owns configured visibility and scan scope, and `audio_catalog.py`
owns generated audio: the output-audio history, temp Preview Audio results and
their copy into `output/audio/`, deletion, and streaming from the output and temp
folders only. Character voices are the Anomalous_TTS node's data and never pass
through this plugin's backend.
`version_manager.py` runs git against the plugin checkout only (no shell, no
prompts, `CREATE_NO_WINDOW` on Windows) and accepts only tags that appear in the
published release list. It refuses switches while tracked files are modified,
never rewrites a local branch that has diverged from the remote, and reports
stable error codes (`dirty`, `diverged`, `offline`, `unknown_tag`, ...) that the UI
localizes. Switching changes code only; a ComfyUI restart applies it.
Offline inference sidecars use non-positive Civitai IDs as sentinels. Metadata
normalization must not expose those values as release-page URLs or resolved
model/version identities; only positive IDs may form a Civitai source link.
Prompt Notes use `workflows/anomalous_notebooks`. First access copies legacy
Combos (搭配, formerly Prompt Notes) use `workflows/anomalous_notebooks`. First access copies legacy
`api/notebooks` records without deleting originals or overwriting current notes.
Conflicts receive a deterministic recovered filename. A completion marker makes
the copy retryable after write failure and prevents deleted notes reappearing.
@@ -101,8 +128,7 @@ small bounded safetensors header is acceptable; reading a whole multi-gigabyte
model to discover metadata is not.
Background work claims state before launching. Folder scans use
`.scan_in_progress`; global quick scans use `.global_scan_in_progress`; deep
missing-model scans use `GLOBAL_SCAN_STATE` and the corresponding status route.
`.scan_in_progress`; global scans use `.global_scan_in_progress`.
Marker files are versioned JSON records containing backend session, owner PID,
worker PID, and job ID. Status checks validate ownership and process liveness;
file existence alone is not proof that a scan is active.
@@ -117,6 +143,22 @@ file count, current index, and filename. Status responses merge worker progress
with parent-owned folder progress. The frontend can reconstruct this state by
polling after its UI has been reopened.
`scraper.py --report-file` appends one JSON line per model it changed (`matched`,
`inferred` with its reason, `failed` with the error, renames and covers) and events
(`civitai_down`, `done` with the unchanged count). One model's error is reported and
the scan goes on. `ScanJob` in `api/scan_report.py` reads each folder's report when its
worker exits and, before the scan's marker is released, writes the scan's result to
`user/anomalous/last_scan.json` and the activity log, so whoever sees the scan end can
read its result. The scan page's picked or listed models go to `/anomalous/scan_all` as
`targets` (`--targets-file` per folder), so one scan has one result.
An unmatched model's `.info` (`id` -1) keeps why: `anomalous_unmatched_reason` is
`not_found` (Civitai answered 404), `network` (no answer after the retries) 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
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.
## Metadata and cache behavior
The output gallery keeps one ordered directory snapshot for at most ten seconds
@@ -140,14 +182,20 @@ remain browser-cacheable.
Preview lookup tries a contained exact relative path first and recursively walks
the library only for unresolved basename fallbacks. This locates presentation
for a model value already supplied by ComfyUI; it is not Model Doctor discovery.
for a model value already supplied by ComfyUI; it is not Model Check discovery.
Balanced grid thumbnails are derived, longest-edge 512 px WebP files in
ComfyUI's temporary area. Their cache is keyed by source real path and physical
signature and capped at 256 MiB with oldest-accessed eviction. The original mode
`<user folder>/anomalous/cache/card_thumbnails` (not ComfyUI's temp folder, which
ComfyUI empties on every start). The cache is keyed by source real path and
physical signature and capped at 256 MiB with oldest-accessed eviction; the
settings page reads its size (`GET /anomalous/card_cache`) and empties it
(`POST /anomalous/card_cache/clear`). A video cover's card image is its first frame
(`variant=poster`, decoded with PyAV, which ComfyUI ships); the card shows it until
the video plays. Listing a model type queues the covers without a card image for one
background thread, one decode at a time, at most 512 waiting. The original mode
serves source covers, and detail views always use originals. Derived media never
modifies or sits beside a user's cover; unsupported, animated, or failed inputs
fall back to the original.
fall back to the original (a video without a readable frame has no poster).
## Model sidecar and cover lifecycle
@@ -165,7 +213,9 @@ media.
- Foundation components—`vae`, `vae_approx`, `clip`, `text_encoders`, and
`clip_vision`—are never physical-rename targets. UI and all backend entry
points enforce the same denial.
- Main model extensions (`.safetensors`, `.ckpt`, `.pt`, `.bin`) are never
- Model files are `MODEL_EXTENSIONS` in `api/model_constants.py` (`.safetensors`,
`.ckpt`, `.pt`, `.pth`, `.bin`, `.sft`, `.gguf`); listing, counting, search and
lookups test names with `is_model_file()`, without case. They are never
sidecar suffixes. Cleanup must not delete a same-stem model with another
extension. If such a sibling remains, ambiguous stem-keyed sidecars remain.
- Rename/delete/reset uses centralized immutable suffix tuples and a constant
+134 -97
View File
@@ -6,15 +6,18 @@ localization, media, or explicit canvas mutations.
## Bootstrap and module ownership
The verified AMB0/AMB1 workflow share-code Import / Export Center is available
from Toolbox through `window.AMB_WorkflowShare.showUnifiedModal()`. Both directions
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 and closed Material Library file import. Image/workflow hash
package import/export. Image/workflow hash
injection, host saving, and ordinary image downloads remain unchanged.
ComfyUI loads JavaScript in the extension `WEB_DIRECTORY` as ES modules.
`web/main.js` registers `Anomalous.ModelBrowser` and coordinates host hooks.
`browser.js` defines the browser class and binds extracted feature methods;
`browser_entry.js` owns the single browser instance and all entry presentation;
`browser_entry.js` owns the single browser instance and all entry presentation,
including `window.anomalous_open_voice(character)`, which the Anomalous_TTS node's
character menu calls to open one character's voice card (`browser.openVoice`; the
node pack documents it in its `docs/INTERFACE.md` §6);
`interface_settings.js` owns locale and theme preferences. A syntax error or duplicate
top-level declaration in any imported module can prevent registration and make
the entire entry disappear. For affected modules, validate syntax and module linking
@@ -22,22 +25,34 @@ via Node's experimental VM modules (`vm.SourceTextModule`) and verify
the real ComfyUI runtime creates the configured entry. Floating trigger styling in
the ordered stylesheet bundle rooted at `web/styles.css` uses dynamic `1em` SVG
scaling and flex centering to guarantee
consistent visual presentation across all configured trigger sizes.
consistent visual presentation across all configured trigger sizes. The unsaved
default position is defined in CSS outside the sidebar dock; `browser_entry.js`
drags with pointer capture and only persists real drags, and `entry_controls.js`
validates and clamps saved coordinates (`clampFloatingTriggerPosition`). Resetting
the position clears the saved coordinates so the CSS default applies again.
Web modules are imported by plain relative paths. Do not add `?v=` query strings to
module imports: a different URL is a second module instance with its own state.
Freshness comes from the `Cache-Control: no-cache` header on this plugin's files.
Major UI panels live in `web/modules/ui_*.js`. Shared browser state remains on
the `AnomalousBrowser` instance. Pure parsing, normalization, comparison, and
transaction helpers remain in focused modules rather than acquiring DOM state.
`ui_sidebar.js` assembles the browser shell and folder navigation.
`ui_settings_hub.js` owns settings and model-card preferences, while
`ui_toolbox.js` owns the catalog, fixed shortcut actions, and tool dispatch.
The Material Library shortcut remains a native ComfyUI command/keybinding; a
deferred window-key fallback invokes the same command path only when the host
did not bring the library forward, so handled shortcuts are not executed twice.
`ui_sidebar.js` assembles the browser window and the model folder list; the left
icon rail (`ui_shell_rail.js`), page navigation and the list column's open state
(`ui_shell_nav.js`), the home page (`ui_home.js`) and the window frame
(`ui_shell_frame.js`) are their own modules. Every page change goes through
`owner.goTo(page)`. `ui_settings_hub.js` owns the display preferences behind
`owner.displayPrefs`, which the settings page (`ui_settings_page.js`) changes,
while `ui_rail_tools.js` owns the rail's tool buttons.
The Prompt Studio shortcut (its command and setting ids still say "materials") is a
native ComfyUI command/keybinding; a deferred window-key fallback invokes the same
command path only when the host did not open the studio, so handled shortcuts are not executed twice.
`ui_browser_navigation.js` owns shared panel hiding, recoverable detail cleanup,
and workspace return. `ui_scan_wizard.js` owns scan configuration and scan-launch polling,
`ui_folder_manager.js` owns folder visibility/order and presentation-mode
changes, and `ui_help.js` owns the help dialog. Public entry functions remain
and workspace return. `ui_scan_page.js` owns the scan page and `scan_runner.js` scan launch and polling,
`ui_folder_manager.js` is the settings page's Model folders view (grouping mode,
visibility and order, saved on each change). Public entry functions remain
browser-instance methods so existing actions share current browser state.
Update-guide content and UI lifecycle are separate modules. The header and Help
@@ -49,32 +64,38 @@ bootstrap or reuse its content version as a feature flag.
The main surfaces are:
- Sidebar shell: navigation and persistent scan controls; focused child modules
own the scan wizard, folder manager, and help dialog.
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_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).
`ui_gallery_detail.js` owns its singleton lifecycle and cache, `ui_image_stage.js`
owns header/zoom/filmstrip interaction, and `ui_image_inspector.js` owns metadata tabs.
- Workspace: Prompt Notes, Workflow Recipes, and the Material Library. Prompt Note
catalog/persistence, editing, and canvas creation are separated across
`ui_notebooks.js`, `ui_notebook_editor.js`, and `notebook_canvas.js`. Material
discovery/pagination, cards, detail, and node application are separated across
`ui_materials.js`, `ui_material_cards.js`, `ui_material_detail.js`, and
`ui_material_application.js`.
- Node Assistant/Model Doctor: selected-node actions, diagnostics, parameter
presets, and missing-model recovery. `ui_doctor.js` coordinates diagnosis and
global scans, `ui_node_assistant.js` owns assistant history, `ui_node_model_picker.js`
owns the native-widget model replacer, and `ui_node_presets.js` owns parameter
preset previews and application.
- 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
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).
- 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.
`ui_node_assistant.js` owns the current-node panel and model history, `ui_node_model_picker.js`
owns the native-widget model replacer, and `ui_node_parameters.js` with
`node_parameter_sets.js` own the panel's one list of saved values for the node type;
`ui_apply_receipt.js` is the receipt with Undo after a panel writes to a node.
`ui_dom.js` owns generic `text` and `jsonResponse` helpers.
`material_inspector.js` shares image metadata helpers and node-parameter rendering
between the material and image-inspector modules. The workbench does not import
the library UI, keeping this dependency chain acyclic. Library discovery uses
server-side filters and pages with cancellable requests. Detail entry fetches
metadata and scoped node blocks; the complete workflow loads only on the explicit
open action. See `material-library.md` for the API and persistence contract.
`material_inspector.js` holds the image workbench's metadata helpers and
node-parameter rendering. See `material-library.md` for the material files' API and
persistence contract.
## Entry modes and host integration
@@ -83,8 +104,9 @@ 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 `Ctrl + Shift + M` browser binding and the
`Ctrl + Shift + L` Material Library binding. Shortcut customization delegates
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
to ComfyUI's command/keybinding panel and recorder; the plugin does not install
a parallel global keyboard listener or maintain a second shortcut preference.
@@ -130,14 +152,15 @@ query parameter.
## Model source component links
The source hub uses `inferModelFolderTypes` for workflow component labels and
The Sources view (`ui_model_sources.js`, data in `model_source_links.js`) uses
`inferModelFolderTypes` for workflow component labels and
category-scoped, exact-path local metadata reads. Context requests are batched
to respect the endpoint's 16-item limit. Missing source links and unavailable
native model choices are independent states; dynamic choices are not treated
as evidence of absence. Native extensionless component choices and PTH/GGUF
references remain visible even when the backend cannot locate an individual file.
`model_source_data.js` owns library-result shaping and the source hub's pure
`model_source_data.js` owns library-result shaping and the Sources view's pure
main/component grouping and filtering. CLIP, text encoder, CLIP Vision, VAE, and
preview-VAE entries live in a session-local, default-collapsed disclosure; the
source-status pills and search affect both main models and components. Collapsed
@@ -204,9 +227,9 @@ widget objects.
## Optional capabilities
`web/hash_resolver.js` may hook a compatible graph serializer to carry model
provenance. If the host API is unavailable, it disables only that integration
with a useful warning. It must not prevent `main.js` from registering or remove
`web/hash_resolver.js` keeps the local hash cache and may hook a compatible graph
serializer to carry model provenance. If the host API is unavailable, it disables
only that integration with a useful warning. It must not prevent `main.js` from registering or remove
the visible browser entry.
Network-backed enrichment is explicit and recoverable. An unavailable Civitai
@@ -215,66 +238,74 @@ browsing, editing, or already stored data.
## Prompt Studio ownership
The studio supports copying prompt text and saving combinations to the local
Material Library. Standalone prompt-plan JSON export has been removed and the
Material Library file-import entry is closed.
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.
Source cards automatically refresh on studio open, browser focus/visibility, and
every 30 seconds while the page is visible (scheduled after the previous request).
Saving a studio combination also refreshes immediately. `sourceKind: 'material'`
cards are reconciled by filename and role from a complete paginated snapshot;
renames/content edits replace them and deleted sources disappear. Failed or
cancelled reads retain existing cards. Equal text from different sources retains
each source identity. Built-in and unsaved local cards remain independent.
Library cards show their origin and have no delete action in the studio. Mixer
blocks are editable copies; refreshing sources must not modify existing drafts.
The deck owns its refresh timer, listeners and AbortController and releases all
of them when its view closes. New-card role is supplied by the workbench callback.
The target (`ui_prompt_target.js`) is polled every 400 ms. Selecting another node with
prompt boxes switches to it; empty canvas or a node without boxes keeps the current
one; the ✕ beside the node's name switches to the draft; a node that leaves the open graph drops back to the
draft. Each change is one `applyNodeMaterialValues` write (a Ctrl+Z step). The studio's
Undo takes its own writes back newest first and stops with `materialUndoChanged` when a
box changed elsewhere; switching target clears it. Box editors re-read their widget on
each poll and redraw unless a tag is being edited, a translation is pending or the text
view has focus.
The studio has one standalone drawer; the former embedded side/full composer
and material import drawer are removed. Existing browser integration continues
to call `openPromptStudio(owner)`, and external prompt dispatch uses
`appendPromptToStudio(owner, text, isPositive, title)`.
`prompt_tags.js` splits a box on top-level commas and line breaks and keeps each
separator, so editing one tag leaves the rest of the text, line breaks included, as it
was; a weight is `(tag:w)`. Inserting skips tags the box has already (same words, any
weight). Typed or edited text with letters that are not plain English (`needsEnglish`:
Chinese, kana, hangul, Cyrillic, accented Latin) is translated to English before it is
written; a box that holds such tags offers to translate them, a translation the box
has already removes the tag instead, and the result is written only if the box still
holds the text the request was made from. Meanings (`prompt_gloss.js`) are shown in the
language picked in the top bar (one of `GLOSS_LANGUAGES`, none by default, remembered in
local storage with the view; an older "Chinese on" setting reads as Simplified Chinese).
They are asked for in batches, kept for the session per language, and a failed lookup
leaves that box's meanings in that language blank instead of retrying. The backend
skips DeepL for a target it does not take and lets MyMemory detect a source that is
neither English nor Chinese.
The cards are three built-in ones and the saved prompts (prompt-kind material files,
`prompt_material_source.js`). The saved list is asked for on open, every 30 seconds
while the page is visible (scheduled after the previous request), on focus/visibility
and after a save; a newer request cancels the older one and a failed one keeps the
current cards. List summaries are not prompt bodies: a prompt's text is read through
`material_prompt_data.js` once per filename and timestamp, since a saved prompt's text
never changes. A card's role decides its box: a positive card never goes into a
negative box or the reverse; boxes of unknown or both roles take either. A saved
card's preview renames it (`update_material`, tags kept) or deletes it
(`delete_material`, to the Recycle Bin). A card, or a box's handle with the box's text
at drag start, drags onto the canvas (`prompt_card_drag.js`, one undo step); over the
drawer the drag passes through to the boxes.
| Module | Owned state and responsibilities |
| --- | --- |
| `ui_prompt_composer.js` | Active drawer, docking width/side, trigger visibility and plan-loading request |
| `ui_prompt_workbench.js` | Owner-backed draft, active role, block editing, ordering, write/copy/save |
| `ui_prompt_source_deck.js` | Source cards, filters, new-card form, preview popover and current sync request |
| `ui_prompt_inspector.js` | Full-text inspection window, role tab and its translation lifetime |
| `prompt_studio_data.js` | Starter cards, display categories, draft/block initialization and synthesis |
| `prompt_composition.js` | Pure plan conversion, sorting and composition; no DOM ownership |
| `prompt_material_source.js` | All-page prompt discovery, detail loading and reconciliation by source identity |
| `ui_prompt_composer.js` | Active drawer, docking width/side, Esc, trigger visibility and reopening the browser |
| `ui_prompt_workbench.js` | Top bar, view and meanings switches (remembered), saving a box as a card |
| `ui_prompt_target.js` | Which node or draft is edited, the poll, node/draft writes and the studio's Undo |
| `ui_prompt_box_editor.js` | One box: tags or text, selection, weight, edit, remove, reorder, drops, typing, translation |
| `ui_prompt_source_deck.js` | Built-in and saved cards, search, new-card form, rename/delete and the sync request |
| `ui_prompt_card_popover.js` | Card preview: hover corridor, pin, copy/add/rename/delete actions |
| `prompt_card_drag.js` | A card or box dragged onto the canvas: box fill or new prompt node, passing through the drawer |
| `prompt_tags.js` | Pure tag split/join, weights, insert/remove/replace/move keeping separators |
| `prompt_gloss.js` | Meaning languages, session cache and batched lookup of tags' meanings |
| `prompt_material_source.js` | Saved prompts as cards (paginated list, text read once) and saving a card |
| `prompt_composition.js` | Starter cards (`PROMPT_PRESETS`, also the combo group's negative), composing a saved plan's text, prompt titles and categories; no DOM |
| `ui_lifecycle.js` | View-scoped listeners, AbortSignal, cleanup callbacks and resizing |
Child views receive their container and narrow callbacks. The workbench keeps
`owner.promptPlanDraft` for reopening and exposes only `updateAll` / `addBlock`
through `owner.sidePromptComposerControl`. The source deck keeps its state local
and returns extraction/refresh/sync actions instead of assigning new owner fields.
Every close route (Close, Esc outside a text field, replacement) disposes the same view
scope: the poll and sync timers, requests, translation and text timers, listeners and
resize handlers. Esc in a studio text field only leaves the field, and Esc with a pinned
card preview leaves the studio open. Closing during resize releases move/up listeners
and restores body cursor and selection styles. A save already sent to the server may
still complete, but must not reopen or repaint a disposed view.
Draft `plan.parts` is the authoritative editable representation. Full-text
inspector edits merge the enabled blocks in the edited role into one block,
preserving disabled blocks and the opposite role. The inspector explains this
behavior before editing. Copy, save and reopen derive text from the same parts.
Every close route (button, Escape, backdrop, replacement and parent close) must
dispose the same view scope. Parent close also closes its inspector. An Escape
handled by an inspector must not close its parent in the same event dispatch.
Closing during resize releases move/up listeners and restores body cursor and
selection styles. UI reads are cancelled on close; a save already sent to the
server may still complete, but must not reopen or repaint a disposed view.
Initial and automatic source synchronization share a paginated loader. List
summaries are identifiers, not prompt bodies: each unique filename is resolved
through `material_prompt_data.js`. Reconciliation uses source filename and role,
so equal text from different materials retains each origin. A newer sync cancels the old
one and only the current result updates the deck.
The translator captures graph, node, widget and widget value before an async
write. It revalidates that destination and the input after translation; close,
selection changes and intervening widget edits invalidate the pending write.
Explicit English translation applies to all input languages, including kana
and Korean. Transport errors and rejected bridge responses remain local errors;
Explicit English translation applies to all input languages, including kana and
Korean. Transport errors and rejected bridge responses remain local errors;
provider-specific validation belongs to the backend translation route.
## Visual styling and theme architecture
@@ -287,19 +318,25 @@ guards the unique ordered list and byte-for-byte reconstructed bundle. Shared `-
tokens express surfaces, text, borders and control shapes; theme overrides must
be scoped to `.theme-abyssal-scarlet` rather than changing unrelated surfaces.
Colours come from the tokens in `00-foundation-models.css`, which both themes define:
surfaces (`--amb-bg-page`, `-panel`, `-card`, `-card-hover`, `-input`), text
(`--amb-text-main`, `-soft`, `-muted`, `-dim`), borders (`--amb-border`, `-strong`,
`-hover`), the primary button (`--amb-btn-primary-*`) and one accent, `--amb-link`, for
links, selection and focus; its tints are `color-mix(in srgb, var(--amb-link) N%,
transparent)`. CSS injected from JS and inline styles use the same tokens. Literal
colours stay only where the colour is the meaning: status (red, green, amber), prompt
roles, voice emotions, canvas node colours and translucent shadows or overlays.
Studio drawer rules keep the source deck at the screen edge and the assembly
track next to the canvas. Common geometry is shared between dock directions;
direction-specific rules set column order and separators. The removed embedded
composer's `#anomalous-container.anomalous-docked` overrides must not return.
Before adding an override or `!important`, locate and edit the owning rule.
Older component and theme overrides elsewhere in the ordered bundle still need a
separate, visually verified consolidation.
Older theme overrides in `08-theme-gallery-overrides.css` predate the tokens; many are
now redundant and can go as the rules they override are touched.
## Verification
Use `node --experimental-vm-modules tests/prompt_ui_lifecycle.mjs` for actual
module open/close, nested Escape, resize cleanup, block insertion and delayed
node writes. `tests/prompt_material_source.mjs` covers pagination, deduplication
and cancellation; `tests/translation_service_contracts.mjs` covers the HTTP
bridge. The UI fixture simulates DOM and ComfyUI APIs and does not replace
`tests/prompt_tags.mjs` covers splitting, joining, weights and tag edits keeping
separators. The UI fixture simulates DOM and ComfyUI APIs and does not replace
checking the real host, layout, focus, drag gestures and theme rendering.
+112 -275
View File
@@ -1,9 +1,21 @@
# Material Library
# Saved prompts and node values (material files)
The Material Library is a user-curated reuse layer. It does not replace
Workflow Recipes, Prompt Notes, Node Assistant, or Model Doctor. The initial
material kind is an image workflow snapshot captured from a generated PNG that
contains a complete ComfyUI UI workflow.
Material files hold what the user keeps for reuse that is not a whole workflow or a
combo: saved prompts and saved node values. There is no Material Library page any
more; each kind shows where it is used:
- Prompt kinds (`prompt_plan`, older `prompt_text` / `prompt_note_bundle`, and
selections made only of prompt nodes) are Prompt Studio's library cards
(`prompt_material_source.js`), renamed, deleted and dragged onto the canvas there.
- Value kinds (`node_parameter_selection`, `image_node_selection`,
`recipe_parameter_selection`) are listed per node type in Current node
(`node_parameter_sets.js`, with the recipes' own parameter sets), applied and deleted there.
- `image_workflow_snapshot` records are whole workflows kept before recipes took over;
the Workflows page offers to move them to recipes. Their node blocks still count as
saved values for Current node.
Whole workflows are Workflow Recipes; a main model, LoRAs and a prompt kept together
are combos (`anomalous_notebooks`). Neither is copied into material files.
## Persistence and API ownership
@@ -11,7 +23,8 @@ contains a complete ComfyUI UI workflow.
normalization; `api/material_assets.py` owns source inspection and private image
copies; `api/material_store.py` is the sole owner of persistence locking, the
summary cache, query/update/delete, and recipe-source resolution. `api/materials.py`
owns HTTP request/response mapping and narrow compatibility entry points. Records live under
owns HTTP request/response mapping and narrow compatibility entry points;
`api/node_material.py` saves one canvas node's current values. Records live under
`user/<profile>/workflows/anomalous_materials`; source PNG copies and bounded
WebP previews live in a private `.assets/<material-stem>/` directory.
@@ -19,16 +32,10 @@ The backend accepts output-image descriptors only after applying the shared
filename and containment checks. It reads bounded embedded metadata, requires a
valid UI workflow, copies at most 64 MiB of source image data, validates the
graph with the Recipe workflow validator, and writes JSON atomically. List
responses omit the full workflow. The explicit image-inspection route returns
the validated workflow plus summary node blocks only after the user opens one
image detail. This lets the workbench display exact `widgets_values`, bounded
node `properties`, mode, and volatile widget indexes without allocating a
second full-PNG buffer in the browser. Direct PNG parsing remains a temporary
compatibility fallback for an older running backend. Exact metadata is kept in
a small 16-entry in-memory LRU, and duplicate workflow/widget fields are
discarded before caching. Local-model preview resolution is deferred until the
Models tab is opened and then cached with that image's metadata. Asset reads
require both a valid material record and a contained private asset path.
responses omit the full workflow. The image-inspection route returns the validated
workflow plus summary node blocks for the image workbench, which keeps exact metadata
in a small 16-entry in-memory LRU. Asset reads require both a valid material record
and a contained private asset path.
Saving stages the image, preview, and JSON together below a private temporary
directory. Assets are promoted before the record becomes visible. A failed final
@@ -38,41 +45,34 @@ Existing records and their images are never overwritten by this path.
Material discovery caches up to 4,096 summaries, keyed by the contained record's
real path, size, mtime, and ctime. It never caches full workflows. A directory
inventory detects additions/deletions and re-parses only changed records. Node-type
lookup filters summaries before opening matching workflows; asset authorization
uses the same validated summary cache. Callers receive independent summary copies.
lookup filters summaries before opening matching workflows. Callers receive
independent summary copies.
The library requests 48 summaries per page. `materials` accepts `q` (name, tags,
or node type), `tag`, `kind`, `category`, exact `node_type`, `page`, and `limit` (at most 100), and returns
`total`, `page`, `pages`, and the library's available `tags`. Older callers without
page/limit retain their complete summary response. Name/tag edits use
`update_material`, preserve the source workflow, and atomically replace the record.
Tags are trimmed, deduplicated without case sensitivity, and limited to 20 tags
of 60 characters each; older records without tags remain valid.
`materials` accepts `q` (name, tags, or node type), `tag`, `kind`, `category`, exact
`node_type`, `page`, and `limit` (at most 100), and returns `total`, `page`, `pages`,
and the available `tags`; callers without page/limit get every summary. `category`
groups sources before pagination: `workflow` contains full snapshots; `prompts`
contains note/text/plan kinds and selections consisting only of prompt nodes;
`params` contains other node/recipe selections; `all` (the default) is every category
but `workflow`. A snapshot moved to a recipe (`mark_material_moved` records
`moved_to_recipe`; the file stays) is listed in no category. Name/tag edits use
`update_material` and atomically replace the record. Tags are trimmed, deduplicated
without case sensitivity, and limited to 20 tags of 60 characters each.
`category` groups sources before pagination: `workflow` contains full snapshots;
`prompts` contains note/text/plan kinds and selections consisting only of prompt
nodes; `params` contains other node/recipe selections. `all` is the default.
Exact `kind` remains compatible. `material_prompt_data.js` reads authoritative
detail fields for the studio: notes use `note.promptEn`, plans compose saved
parts, and workflow selections use top-level `prompt_groups`. The import drawer
pages summaries and fetches prompt text only on inspection; search covers names,
tags and node types. Side/full editors have separate view references and share
the active draft; closing or resetting their importer cancels pending requests.
Save, edit, and delete serialize their writes within the server process. Before
publishing a new record, save compares the source PNG SHA-256, material kind, and
selected node IDs against existing summaries. A duplicate returns HTTP 409 with
`status: duplicate` and the existing name/filename, without leaving new assets.
An explicit retry with `allow_duplicate: true` creates a separate copy. This is
exact-source duplicate detection, not visual similarity or model identity.
Save, edit, and delete serialize their writes within the server process. An image
save compares the source PNG SHA-256, material kind, and selected node IDs, and a
prompt plan or node values compare their content signature, against existing
summaries. A duplicate returns HTTP 409 with `status: duplicate` and the existing
name/filename, without leaving new assets; `allow_duplicate: true` creates a copy.
Deleting moves the record and its assets to the Recycle Bin.
The route family is:
- `POST /anomalous/inspect_image_material`
- `POST /anomalous/save_image_material`
- `POST /anomalous/save_parameter_material`
- `POST /anomalous/save_prompt_note_material`
- `POST /anomalous/save_prompt_plan`
- `POST /anomalous/save_node_material`
- `POST /anomalous/mark_material_moved`
- `GET /anomalous/materials`
- `GET /anomalous/material_full`
- `GET /anomalous/material_asset`
@@ -80,169 +80,60 @@ The route family is:
- `POST /anomalous/update_material`
- `POST /anomalous/delete_material`
## Frontend ownership
`material_full?include_workflow=0` returns metadata and scoped node blocks (with
`workflow_hashes` restricted to them) and no complete workflow; `include_workflow=1`
returns the original workflow of a complete snapshot. Selected-node records never
return a full workflow and reject explicit workflow requests with HTTP 403.
`ui_materials.js` owns Workspace discovery, filters, pagination and CRUD
coordination; `ui_material_cards.js` owns summary cards; `ui_material_detail.js`
owns names/tags and full-workflow detail/handoff; `ui_material_application.js`
owns selected-node tracking and explicit application. `ui_gallery_detail.js`
owns the single image-workbench lifecycle/cache, while `ui_image_stage.js` owns
media/zoom/filmstrip interaction and `ui_image_inspector.js` owns image/node
inspection and saving. These views use `material_inspector.js` for shared metadata helpers and exact node
parameter rendering; the workbench does not import the library UI.
`ui_gallery.js` and
`ui_recipe_detail.js` only supply non-invasive gallery entry points. Main
Gallery image clicks open the focused pan/zoom viewer, while the dedicated
parameter action opens the Image Detail Studio. Drag, delete, and cover-selection
behavior remains independent of those two entry points.
`ui_node_presets.js` presents Node Assistant presets; it shares node application
with the library through `ui_material_application.js` and `node_material_actions.js`.
## Kinds
`ui_recipe_detail.js` can publish the active Recipe parameters or the selected
Parameter Notebook as `recipe_parameter_selection`. The primary panel saves all
reusable widget-bearing nodes; the raw-node inspector supports direct single-node
save and explicit multi-selection. Prompt cards expose the same direct save.
The backend reloads the named Recipe/Parameter Notebook from its contained user
directory instead of trusting a browser-supplied workflow.
Records declare `schema_version`, `kind`, and `capabilities`.
Material cards remain compact, summary-only discovery items. “View Details”
switches the library itself to a master-detail inspector: a contained reference
image stays on the left, while scope, model references, and reusable node blocks
are grouped on the right; exact widget values remain nested under each node.
Only the opened material fetches `material_full?include_workflow=0`: metadata and
scoped node blocks, with no complete source workflow. Only prompt cards initially
expand; other node cards build their parameter DOM on first expansion and reuse
it on later toggles. Expand/collapse-all follows the actual card state. Selectable
cards in the image workbench remain closed initially.
Returning to the list aborts
an unfinished request and releases the detail payload/DOM so browsing never
accumulates full workflows in browser memory.
- `image_workflow_snapshot`: a generated PNG's complete UI workflow (no longer
created; kept images are recipes).
- `image_node_selection`: some nodes of an image's workflow, saved from the image
workbench (a node card, checked cards, or the generation settings). The workflow
stays as provenance; list, count and lookup expose only the selected blocks.
- `recipe_parameter_selection`: nodes of a recipe or one of its parameter sets, saved
before Current node listed recipe sets itself (no longer created). No image asset;
only `apply_node_parameters`.
- `node_parameter_selection`: one canvas node's values, saved from Current node.
- `prompt_plan`: an image-free prompt with `compose_prompt` (below).
- `prompt_text` / `prompt_note_bundle`: prompts copied from Prompt Notes before combos;
read as prompt cards (`note.promptEn`).
Opening a complete snapshot explicitly requests `include_workflow=1`, which
returns the original workflow and an empty `node_blocks` array to avoid duplicate
widget payloads. The original seed and hash evidence remain intact. The server
reads, shapes, and serializes these responses in a worker thread. Selected-node
records filter before copying widget values; they never return a full workflow
and reject explicit workflow requests with HTTP 403. For legacy callers, omitting
the flag retains both workflow and node blocks for complete, openable snapshots.
Only `0`, `1`, or an omitted flag are accepted.
Prompt roles in image selections are inferred from workflow topology first, with
node-title hints as a fallback. The image workbench shows the inferred role beside
every prompt node and allows a positive, negative, shared, unknown, or ignored
override, which travels into the saved record but never writes back to a Recipe.
Search is debounced and each list request cancels its predecessor. Changing a
filter resets the page; returning from detail preserves the current filters.
Card activation supports Enter/Space as well as mouse clicks. A name/tag edit
refreshes summary cards and filter choices. Shared confirmation dialogs sit above
the image workbench, whose keyboard shortcuts yield while a dialog is open.
## Keeping an output image
A full material workflow is exact and retains its seed. Node-sized reuse is a
preset operation and therefore uses the existing transactional parameter
application path, which skips known volatile seed widgets. The lookup endpoint
filters blocks by exact `node.type`; when more than one source node matches, the
user chooses the block explicitly.
The gallery's star and the image workbench's Keep (`ui_keep_menu.js`) keep an output PNG
as one of three things, each recording the image as its source: the whole workflow as a
Workflow Recipe (`recipe_save.js`, `source_image`), its main model, LoRAs and positive
prompt as a combo (`image_keep.js`; models are looked up among this computer's files with
`/anomalous/resolve_paths_to_previews`, missing ones are left out; the notebook file keeps
`source_image`), or its positive and negative prompts as a `prompt_plan` material
(`/anomalous/save_prompt_plan` stores `source.image`; the same plan saved again answers 409
with the existing one). The image's workflow is laid on a detached graph and read with
`extractRecipeMetadata`, so prompts follow the wiring as in a recipe. `GET
/anomalous/kept_images` (`api/kept_images.py`) lists each image's recipe, combo and prompt;
the star is filled when any exists, and a kept row opens it or removes it: what was kept goes to
the Recycle Bin through its own delete route (`delete_recipe`, `delete_notebook`,
`delete_material`) after a confirmation, and the star follows.
Every node card in the image workbench can be saved directly as an
`image_node_selection`; checking several cards exposes one colocated save action
above the node list. The initially hidden footer remains dedicated to the full image and
workflow snapshot rather than mixing both concepts in a scope selector. The
original workflow remains in a selected-node record as source provenance, but
list/count/lookup APIs expose only the selected blocks. Such a material
deliberately lacks `open_workflow`; the library and Node Assistant apply its
parameters to one node without opening the hidden source workflow.
## Prompt plans
The image workbench also exposes saving on its primary surfaces. The top action
reveals the full-snapshot name/tag form. One generation-settings action captures
the sampler and size nodes together; each prompt node can be saved without visiting
the all-nodes tab. A metric action is intentionally node-sized: it does not claim
to persist one isolated widget value. The former plain-text “share text” action
is not part of this workbench.
`prompt_plan` has no synthetic workflow. `POST save_prompt_plan` accepts a name, tags,
an optional `source_image`, and `plan`: `parts` is an ordered list of up to 100
records with `name` (up to 120 characters), `category`, `role`, `enabled`, `positive`,
and `negative`; top-level `positive` and `negative` hold the composed text. The plan
is bounded to 2 MiB; unknown fields are discarded. `material_prompt_data.js` reads
the text Prompt Studio shows: plans compose their parts, notes use `note.promptEn`,
and workflow selections use `prompt_groups`.
Prompt roles are inferred from workflow topology first, with node-title hints as
a fallback. Image details display the inferred role beside every prompt node and
allow an explicit positive, negative, shared, unknown, or ignored override.
Overrides travel into the saved material but never write back to a source Recipe.
Material details expose the same selector; `update_material` atomically persists
or clears these overrides, and choosing Automatic restores the topology result.
## Model identity handoff
Material storage preserves the workflow's existing `extra.anomalous_hashes`.
When one node block is applied, records scoped to the source node ID are copied
to the target node ID. This is evidence transport, not identity resolution.
Model Doctor remains the authority that decides whether a missing model may be
repaired. A material name, preview, saved path, or size is never promoted to
cryptographic identity.
## Compatibility and future material kinds
Records declare `schema_version`, `kind`, and `capabilities`. New sources such
as Recipe selections and Prompt Note blocks should add explicit kinds or
source metadata without weakening the image snapshot contract. Existing direct
Recipe and Prompt Note use paths remain available; the library is optional
curation rather than a mandatory intermediary.
The currently saved kinds are `image_workflow_snapshot`, `image_node_selection`,
and `recipe_parameter_selection`. Recipe parameter materials intentionally have
no image asset and only advertise `apply_node_parameters`; they cannot replace
the canvas with the source Recipe. A prompt badge on a CLIPTextEncode selection
still describes a workflow prompt node, not a Prompt Note import. Prompt Notes use the explicit kinds described below. The `reference_image` capability currently
means a preserved image that can be viewed; copying it into ComfyUI input or
configuring LoadImage is also future work.
## Prompt Note capture and return
`POST save_prompt_note_material` accepts `notebook_filename` as a provenance
label, `name`, optional `tags`, `scope` (`note` or `prompt`), and an immutable
client snapshot in `note`. It does not use that filename to read a file. The
snapshot includes the latest text before the editor's autosave timer fires.
The server bounds it to the notebook's 2 MiB limit and retains only the supported
prompt, translation, language, base-model, main-model and LoRA fields. Model
objects remain saved selections, never identity evidence. Prompt-only capture
excludes all model fields. `promptZh` is a translation, not a negative prompt.
- `prompt_note_bundle`: entire note, translations and companion model selections.
- `prompt_text`: prompt text and translations without companion models.
Both kinds advertise `copy_prompt` and `restore_prompt_note`, contain no image
assets or synthetic workflow, and never appear in node-type lookup. Explicit
full-workflow requests return 403. Atomic creation and duplicate confirmation
reuse the image-free material writer; deduplication compares content signature
and kind. Editing a note later cannot alter its captured material.
The library copies prompt text or loads the snapshot as a newly named, uniquely
identified Prompt Note. It flushes any pending note before switching, writes the
new record successfully before navigation, and preserves both original note and
material. Canvas use remains the existing explicit Prompt Note action.
`material_feedback.js` supplies a shared save receipt with View Material.
`web/main.js` binds `openSavedMaterial` from `ui_materials.js`; it owns navigation
and workspace return state, keeping workbench imports acyclic. Image details,
recipes and Prompt Notes use that same handoff. Node-sized receipts lead to a
detail that explains reuse through the library or Node Assistant.
Primary surfaces use progressive disclosure: image save form, prompt role
selectors, technical node metadata, companion models, recipe export/source
editing, and recipe multi-selection are revealed on request. Parameter-page
metrics appear once with exact-value copy controls. Display wording is Parameter
Sets (参数方案); existing notebook routes and storage identifiers stay stable.
## Entry points and shared application
The lower-left control opens a standalone Material Library container, with no
workspace tabs and no initial Prompt Note fetch. The upper-right control opens
Workflow Recipes; that workspace retains its Recipes and Prompt Notes tabs.
Both containers share the browser's panel area but have separate headers and
lifecycle state; switching restores the appropriate container. Material Library
file import and its transfer-center entry are closed. It has no independent
image-material bundle exporter. The verified workflow share-code Import / Export
Center lives in Toolbox. Recipe package import/export remains paused, while prompt-plan
JSON export has been removed. Local capture/save actions remain available.
Opening the library with exactly one live selected node enables apply mode.
Summary requests filter by exact `node_type`; activating a card fetches only that
material's scoped detail. No selection means normal detail browsing. An explicit
toggle returns to browsing all materials; prompt-only kinds also use browse mode.
Multiple source blocks of the same type require choosing one block. The full
workflow quick-open button is omitted from apply-mode cards.
## Applying saved values
`node_material_actions.js` is the shared, UI-independent mutation owner. It checks
live graph/node identity, indexes, value types and native combo choices before
@@ -250,84 +141,30 @@ editing. It skips seed widgets, preserves node identity/links/position, calls th
live widget callback and four-argument node hook, and marks the graph dirty.
Values, serialized widget values and target-scoped hash evidence change in one
before/after transaction; hook failures restore their snapshots. Model paths must
already be available in the native combo. This path does not invoke global model
repair after application. Hash transport remains evidence, never verification.
already be available in the native combo. Current node writes only the values that
differ (never seeds, model files or choices this computer lacks) and shows the
receipt with Undo (`ui_apply_receipt.js`); Undo restores values only while the same
live node still holds the applied state. Material storage preserves a workflow's
`extra.anomalous_hashes`, but applying saved values leaves model files and their hash
evidence as they are; Model Check remains the authority on whether a missing model
may be repaired, and a saved name, preview, path or size is never treated as identity.
`material_full?include_workflow=0` includes `workflow_hashes` restricted to returned
blocks, without exposing the hidden workflow. Both library and assistant show the
same application receipt. Undo restores values and scoped hashes only while the
same live node, values, serialized values and target hashes still match the applied
state. Later edits are protected. Unsupported third-party widget side effects
still require real-host compatibility testing.
## Dragging prompts onto the canvas
Selection hooks chain the host's callbacks and batch updates in a microtask;
there is no polling. List browsing retains 48-item pagination and cancellation.
Language changes rebuild visible library/composer text while keeping the current
draft. Node labels reuse ComfyUI's registered localized titles with raw type fallback.
Prompt Studio's cards drag onto the canvas (`prompt_card_drag.js` through
`material_drag.js`). Only an active, same-page drag is trusted; transfer data is a
marker, not an external mutation command. Over the studio drawer the drag passes
through to the studio's prompt boxes. Live node lookup uses ComfyUI canvas coordinate
conversion, canvas bounds and graph hit-testing; DOM-widget surfaces are accepted,
and the graph/canvas identities captured at drag start are checked again at drop.
## Prompt combinations
`prompt_plan` is an image-free material with `compose_prompt` capability and no
synthetic workflow. `POST save_prompt_plan` accepts a name, tags, and `plan`:
`parts` is an ordered list of up to 100 records with `name` (up to 120 characters),
`category` (`general` or `specific`), `enabled`, `positive`, and `negative`.
Top-level `positive` and `negative` hold prompt text. The plan is bounded to
2 MiB; unknown fields are discarded. Atomic writes and explicit duplicate-copy
confirmation reuse the material persistence lock and canonical content signature.
The current panel has simple positive/negative text fields and a beginning/end
drop-position selector. Fragment categories, ordering and enable controls are
deferred. Opening an older plan joins its enabled fragments and final content
into those two fields; a new save has empty `parts`, leaving the original record
intact. Text insertion preserves weights, commas, duplicates and original nonblank
string whitespace. A Prompt Note's `promptZh` translation is never inferred as
negative text. Captured notes and classified workflow prompt groups can populate
the fields; unknown workflow roles are not silently assigned a role.
Saving creates an independent new snapshot. Studio source cards automatically
follow library changes; assembled draft blocks remain independent copies without
model binding. A page-session draft survives library navigation and is replaced
by an existing saved plan only after confirmation; it must be saved before page
reload. Stale detail requests cannot replace the current draft. Standalone JSON
export has been removed, and the file-import entry is closed. The shared save
endpoint still supports local combination saves. The panel also exposes role and target-widget selection,
beginning/end buttons for a selected node, copy controls and guarded undo.
## Canvas drag and drop
`material_drag.js` binds native drag handles to all material cards and to each
prompt field's drag button (`bindPolymorphicMaterialCardDrag`). Only an active,
same-page drag is trusted; transfer data is a marker, not an external mutation
command. During drag, the browser window becomes transparent and stops intercepting
pointer events. End, drop, Escape and loss of focus restore it and remove temporary
event listeners. There is no persistent drag polling or detail request during hover.
Hit testing evaluates live node target acceptance before canvas blank surface drop.
Because nodes sit on the canvas element, testing canvas surface first would hijack
node drops; evaluating `accepts(node, data)` first guarantees node targeting takes
precedence. Live node lookup uses ComfyUI canvas coordinate conversion (including pan/zoom),
canvas bounds, and graph node hit-testing. DOM-widget surfaces are accepted. The
graph/canvas identities are captured at drag start and checked again at drop. The actual
drop node is the target, regardless of the previously selected node. Receipts name
that target and provide the shared guarded undo.
When dropped onto a compatible node:
- Parameter-bearing materials fetch scoped node blocks only after drop and replace
compatible widget values through the transactional parameter path, preserving seed,
node position, and links.
- Prompt materials inject prompt text into target nodes via semantic prompt widget
sniffing (`customtext`, `multiline`, `text_g`, `text_l`, `prompt`, `positive`,
`negative`, and localized labels), relaxing strict node-type matching for third-party
text nodes. Text is cleanly inserted without synthetic prefixes ("负向:" / "正向:").
When dropped onto blank canvas:
- Workflow materials (`image_workflow_snapshot`) trigger full workflow loading
via `openMaterialWorkflow`.
- Prompt materials (prompt notes, prompt plans, prompt node selections) auto-instantiate
native `CLIPTextEncode` nodes at canvas drop coordinates, populating the pure prompt
text, setting bilingual titles (`CLIP Text Encode (Negative/Positive)` /
`CLIP 文本编码器 (负向/正向)`), and applying standard LiteGraph dark theme colors
(`#532323` dark red for negative, `#235327` dark green for positive).
Empty/unsupported targets and canceled drags do not mutate the graph. Neither drop path
queues generation.
While a prompt is dragged (`prompt_drop.js`), every prompt box on the canvas is
outlined in its role's colour (`prompt_boxes.js`: a multiline STRING input by the
node's definition; its role is its own name or the wiring to a sampler). The box
under the pointer is the target (a node with one box needs no aim), the hint names
the node and box, and the text a release would write is laid over the box
(`.anomalous-prompt-preview`, never in the widget's value). A card fills the box with
its text as one undo step; negative text never goes into a positive box. Released on
empty canvas, a card becomes a `CLIPTextEncode` node of its role with its text. Empty
or refusing targets and cancelled drags do not change the graph; nothing queues
generation.
+33 -15
View File
@@ -1,11 +1,11 @@
# Model Identity and Scanning
Read this document for Model Doctor, workflow provenance, missing-model recovery,
hash caches, Civitai metadata resolution, or deep scanning.
Read this document for Model Check, workflow provenance, missing-model recovery,
hash caches, Civitai metadata resolution, or model scanning.
## Identity boundary
Model Doctor recovers the same physical model referenced by provenance embedded
Model Check recovers the same physical model referenced by provenance embedded
in a workflow or image. Local renames and path differences are the problem it
solves, so names cannot also be its proof.
@@ -16,32 +16,38 @@ Allowed automatic evidence is:
3. the model category required by the target widget.
Without a hash, one unique in-category size match is only a candidate. Model
Doctor may show it during an explicit manual check, but it cannot redirect the
node until the user confirms that candidate. Confirmation applies to the current
node only; it does not create a persistent binding. If both hash and size are
Doctor shows it, but it cannot redirect the node until the user takes that
candidate. Confirmation applies to the current node only; it does not create a
persistent binding. If both hash and size are
present and point to conflicting physical files (both match distinct local files),
resolution reports an identity conflict and is rejected. When a requested model hash
simply does not exist in any local file, it returns `{"found": False}` cleanly without
falsely flagging a conflict.
Paths, filenames, source filenames, display/custom names, previews, workflow
fingerprints, and fuzzy/visual similarity are never candidate evidence. They may
fingerprints, and fuzzy/visual similarity never establish identity. They may
be used only after identity is established to return a local dropdown value,
locate presentation media, and verify the value against ComfyUI's native choices.
One exception, for ordinary models only: when the workflow carries no record at
all (a workflow saved without the plugin), the single file of the same name
among the widget's own choices is offered as a candidate (`via: 'name'`); a
size-only candidate whose name also matches is shown as likely (`via:
'name-size'`). Both take a press on their row and never count among the files
put in at once. Several files of that name leave the model ambiguous.
Foundation components—`vae`, `vae_approx`, `clip`, `text_encoders`, and
`clip_vision`—are hash-only automatic-recovery categories. Byte size alone
cannot automatically repair them. When size provenance is available it may be
cannot automatically repair them, and they are never offered by name. When size provenance is available it may be
shown as the same explicit manual candidate, but a supplied hash mismatch never
falls back to a filename or size-only guess.
This is a Model Doctor confidence boundary, not a scanner-support boundary. The
scan wizard may traverse any active registered model folder and can still
This is a Model Check confidence boundary, not a scanner-support boundary. The
scanner may traverse any active registered model folder and can still
calculate a local hash when Civitai has no matching record. Sparse or ambiguous
remote metadata is a reason to require cryptographic identity, not to exclude
the category from scanning.
An existing native combo value remains loadable even if a foundation component's
current local hash differs from stored provenance. Model Doctor shows a
current local hash differs from stored provenance. Model Check shows a
non-blocking identity-change warning instead of declaring the node missing or
replacing it. If the value is absent, redirection requires one exact in-category
hash match. Missing or ambiguous evidence remains unresolved for manual action.
@@ -67,6 +73,11 @@ When provenance injection is enabled, the compatible active graph constructor's
`extraObj.anomalous_hashes[node_id_filename] = {hash, size}` for known model
widgets. The cache covers ordinary models and foundation categories. A
foundation component without a recorded hash receives no size-only provenance.
The same pass writes `extraObj.anomalous_model_sources`: links the workflow keeps
win; a model present here without one gets its own information's link (the
editor's first, else Civitai's), marked `auto` so a newer one replaces it; links
for names no node holds any more are dropped. The live graph is never changed.
The share-code export may leave the links out.
A missing compatible graph API disables only injection and recovery integration,
not the main browser.
@@ -89,9 +100,9 @@ metadata includes the discovered hash, but consumers still associate the record
with the current physical file through the established size/name rules; array
position is not identity.
## Deep scanning
## Scanning
Deep Hash Scan runs outside the aiohttp event loop and identifies a model through
A scan (the scan page) runs outside the aiohttp event loop and identifies a model through
the established fallback sequence:
1. use existing valid file SHA-256 metadata when the scan does not request refresh;
@@ -131,8 +142,15 @@ may write offline metadata for later reuse.
After a hash match—or explicit confirmation of a size-only candidate—the
frontend refreshes ComfyUI's native combo definitions and accepts the returned
path only if it is present in the target widget's choices. Then it updates the
dropdown and clears the missing-model presentation. Background checks may
surface size candidates but never prompt for or apply them.
dropdown through the node's widget hooks, which also clears ComfyUI's
missing-model mark.
Checking never changes the workflow (`model_check.js`). Every opened workflow is
checked without reloading ComfyUI's model lists; when models are missing, a bar
over the canvas says how many and how many hash matches can be put in. Only a
press puts them in: the bar's or the doctor page's for hash matches, the page's
per-row button for a size-only candidate. A scan with "fix the open workflow"
on puts hash matches in when it ends.
Provenance-rich workflows skip the redundant full filename-to-hash cache refresh.
Legacy workflows without injected provenance may refresh it for compatibility.
+11 -7
View File
@@ -1,7 +1,7 @@
# Workflow Recipe Model Lifecycle
Read this document before changing Workflow Recipe model cards, local matching,
partial-canvas append, package export, or Model Doctor integration. UI code may
partial-canvas append, package export, or Model Check integration. UI code may
change freely, but the data ownership and transition rules below are contracts.
## Three representations with different jobs
@@ -12,7 +12,7 @@ A saved recipe keeps model information in three related representations:
will load into a model widget.
2. `params.model_references[]` is the structured recipe record used by the UI,
package system, history, and matching actions.
3. `workflow.extra.anomalous_hashes` is Model Doctor provenance keyed to the
3. `workflow.extra.anomalous_hashes` is Model Check provenance keyed to the
serialized node ID and widget value.
Changing only one representation creates a broken recipe. A path change must
@@ -35,7 +35,7 @@ node ID. Presentation fields must never become identity evidence.
- `user_note`: optional recipe-scoped personal note, at most 1000 characters.
`user_note` is not global model metadata, is not used for matching, and does not
change Model Doctor confidence. Enrichment preserves it by the complete model
change Model Check confidence. Enrichment preserves it by the complete model
reference key. Package export explicitly asks whether to include it; excluding
it removes the field only from the exported current/history records.
@@ -61,7 +61,7 @@ mutating a recipe.
Graph serialization prefers current verified local-cache data. When a widget
value is still missing—or no current cache record exists—it must preserve the
node-scoped provenance already carried by the graph. Clearing it would make a
successfully appended partial recipe lose its Model Doctor identity on the next
successfully appended partial recipe lose its Model Check identity on the next
save.
An empty Hash with a known size represents unverified provenance. It may produce
@@ -80,7 +80,7 @@ full SHA-256. Normal enrichment does not silently replace imported identity.
### Availability and local matching
Availability and previews describe the current computer only. Matching sends
saved Hash/size/category evidence to Model Doctor:
saved Hash/size/category evidence to Model Check:
- exact Hash match: may be offered for explicit recipe application;
- unique size-only match: confirmation-required candidate;
@@ -127,8 +127,12 @@ the same normalization and enrichment boundaries as local recipes.
- `api/recipes.py`: schema validation, enrichment, field preservation, history;
- `api/recipe_packages.py`: export privacy and package boundaries;
- `api/models.py`: Model Doctor evidence and candidate policy;
- `web/modules/recipe_identity.js`: model-reference adapters for presentation;
- `api/models.py`: Model Check evidence and candidate policy;
- `web/modules/recipe_identity.js`: model-reference adapters for presentation:
native loaders plus `ALL_IN_ONE_LOADER_SPECS` (verified serialized layouts),
mirrored by `_model_reference_specs` in `api/recipe_schema.py`. Add a loader
to both tables with its real widget order; never infer a category from a
file name, and skip placeholder values such as `None` / `Baked VAE`;
- `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;
+12 -13
View File
@@ -21,11 +21,11 @@ local saves are unaffected.
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 available from Toolbox.
share-code import/export is a separate, verified feature on the Workflows page's top bar (⇅).
## Product and data model
Workspace contains Prompt Notes, Workflow Recipes, and the Material Library.
Workspace contains Combos (formerly Prompt Notes) and Workflow Recipes.
Recipe Parameter Notebooks are presented as Parameter Sets (参数方案). Internal
notebook route and property names may remain stable for compatibility even when
the user-facing presentation changes.
@@ -92,8 +92,8 @@ 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_model_matching.js`
owns preview resolution plus explicit local matching. Inline persistence is centralized
parameter editing, raw nodes, and preset saving; 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
@@ -105,17 +105,16 @@ directly for image inspection. Shared detail DOM/copy primitives live in
callbacks instead of redrawing one another.
Model names in compact recipe presentation use a basename or official model
name, never a full filesystem path. Saved paths and hashes belong behind advanced
information. A preview or exact path can locate current-machine presentation
name, never a full filesystem path; the saved path is a secondary line and the
detail shows no hashes (Model Check shows that evidence). A preview or exact path can locate current-machine presentation
only after the reference is already understood; it cannot establish identity.
Import matching is a separate explicit recovery action. Unresolved references
are sent to the hash/size/category resolver. A discovered candidate remains
presentation-only until the user chooses Apply match; that action updates the
authoritative workflow widget, model reference, and node-scoped Hash index
through the full-recipe update path and archives the previous recipe. The
author's saved filename or path is never match evidence. Model-reference
`user_note` is recipe-scoped presentation metadata and never match evidence.
The recipe detail does not match or replace models. A model not found under its
saved name is listed as missing, and opening the workflow hands it to Model Check
(model-resolution.md), which finds renamed or moved files on the canvas; saving
the recipe again keeps the change, with the previous version archived.
Model-reference `user_note` is recipe-scoped presentation metadata and never
match evidence.
Recipe-owned model preview snapshots are bounded, content-addressed WebP files
below `.assets/<recipe-stem>/`. They are at most 320 px or 96 KiB each, limited
+7 -10
View File
@@ -10,8 +10,7 @@
| `web/modules/update_guide_data.js` | 当前引导的稳定 ID、步骤顺序、图标和文案键;也提供配置校验 |
| `web/modules/locales.js` | 每个步骤及按钮的中英文文案,均按纯文本展示 |
| `web/modules/ui_update_guide.js` | 显示、翻页、关闭、焦点、版本记录;一般内容更新不要修改 |
| `web/modules/sidebar_actions.js` | 六个底部按钮的短标签、完整名称和用途提示,不改变点击动作 |
| `web/styles.css` | 引导布局和底部图标/文字切换的现有规则 |
| `web/styles.css` | 引导布局的现有规则 |
| `web/main.js` / `ui_sidebar.js` | 父窗口关闭清理,以及上方按钮和帮助页的手动入口 |
## 后续 AI 更新步骤
@@ -24,6 +23,12 @@
6. 运行下方相关测试,再在浏览器验证打开插件不弹窗、上方入口、跳过后手动重开、帮助页重看、Esc 和长文案。
7. 修改现有样式规则,不在文件末尾追加覆盖包。没有明确产品决策,不增加强制阅读、自动跳转、网络请求或额外追踪。
## 音频页使用说明
- `AUDIO_USAGE_GUIDE`(同在 `update_guide_data.js`)是音频页的使用说明,不是更新公告。音频域激活时,右上角 **!** 打开它;图像域照旧打开 `CURRENT_UPDATE_GUIDE`。
- 可选字段:`titleKey` 替换对话框标题;`tour: false` 不显示遮罩导览入口,`tour: [...]` 用这份说明自己的导览步骤(音频页用 `AUDIO_TOUR_STEPS`),不写则是图像界面的导览。导览步骤的目标不在屏幕上时自动跳过。
- 内容跟着功能改,ID 保持 `audio-studio-usage`。完整说明在 `docs/guides/audio-studio.md`,两边要一致。
## 生命周期与记录
- 面板右上角常驻感叹号按钮(`#anomalous-update-notice-btn`),提供完整的用途提示和 accessible name。只有用户点击该按钮或帮助页入口才打开引导;初始化、打开插件、版本变化都不能触发弹窗。
@@ -35,14 +40,6 @@
- 上方按钮和帮助页的“查看更新引导”均使用 `force: true`,允许重看已确认版本。
- 引导不自动点击对应功能,也不修改画布、模型、素材、导入导出限制。
## 底部按钮约束
原按钮保留点击处理器、尺寸和图标。短标签是独立且 `aria-hidden` 的 `span`,
不能占用已用于详细提示的 `::before`/`::after`。扫描状态替换 SVG 后重新配置标签。
语言切换须更新六个标签、完整 accessible name 和用途提示。
悬停与键盘焦点使用相同文字切换;无悬停设备直接显示短文字;减少动态效果偏好禁用位移动画。
**防闪烁与稳定性铁律**:底栏按钮必须显式声明 `cursor: pointer !important; overflow: hidden;`,内部 SVG 与 Label 必须设置 `pointer-events: none !important;`。图标切换文本时**严禁使用 `display: none`**(必须使用 `opacity: 0` 保持物理文档流支撑),以避免浏览器 Hit-testing 丢失目标导致高频触发 `mouseleave` -> `mouseenter` 乒乓震荡闪烁。
## 验证
在插件根目录运行:
-200
View File
@@ -1,200 +0,0 @@
# Anomalous Model Browser 全功能实测审计与问题复原报告
**Audit & Functional Verification Report**
- **测试对象**:`Anomalous Model Browser` (异构模型浏览器 ComfyUI 插件)
- **运行环境**:ComfyUI Portable (Windows x64) / 本地端口 `http://127.0.0.1:8188`
- **当前 Git 分支**:`feature/recipe-model-binding`
- **浏览器会话录屏存档**:`anomalous_deep_test_1789816916045.webp`
---
## 一、 测试操作过程完整复原 (Step-by-Step Reproduction)
本次实测通过浏览器子智能体(Browser Subagent)与本地真实运行的 ComfyUI 实例进行端到端(E2E)交互操作,测试全流程操作复原如下:
### 1. 插件唤起与主界面交互
1. **浏览器进入 ComfyUI 画布**:
- 页面初始加载默认工作流节点(包括 `Load Checkpoint`、`CLIP Text Encode`、`KSampler`、`Anima VAE` 等)。
2. **唤起插件主界面**:
- 点击 WebUI 界面悬浮触发球及左侧快捷立方体图标,`#anomalous-modal` 顺畅展开,居中层叠在画布上。
- 验证右上角 `×` 关闭按钮与侧边栏 `☰` 折叠按钮,动画过渡自然,能顺畅收起并安全回到主画布。
### 2. 模型医生(Model Doctor)全局体检与修复测试
1. **打开体检中心**:
- 点击侧边栏听诊器图标(🩺),切换至 Model Doctor 面板。
2. **体检扫描表现**:
- 面板显示当前画布的节点模型状态:“健康 2 个 / 缺失 1 个”。
- 正确检测到当前画布缺失模型:`Anima\qwen_image_vae.safetensors`。
3. **交互操作测试**:
- 点击右上角“刷新缓存”按钮,控制台正确触发 `app.refreshComboInNodes()` 和 `/anomalous/all_hashes` 刷新。
- 查看缺失模型右侧操作按钮:包含了“深度哈希扫描”、“手动替换”、“查看哈希”以及“Civitai 搜索”链接。
### 3. 素材库(Material Library)与分类检索测试
1. **打开素材库**:
- 点击工具箱或侧边栏素材库图标,成功打开 Material Library 视图。
2. **分类与搜索切换**:
- 点击顶部筛选胶囊:“全部”、“工作流”、“提示词”等分类,卡片即时响应过滤。
- 在搜索框输入关键词 `Kirazuri`,检索到对应的完整工作流素材卡片 `Kirazuri (Anima)_v4.0`。
3. **卡片拖拽交互测试**:
- 测试按住卡片尝试拖拽至画布空白处或节点上,排查拖拽反馈(发现重大逻辑断点,详见下文第二节)。
### 4. 模型扫描向导(Scan Wizard)与设置中心测试
1. **打开扫描向导**:
- 点击侧边栏雷达扫描图标,弹出 Scan Wizard 原生 Dialog 弹窗。
- 查看各项扫描策略配置:包括完整哈希、Civitai 元数据提取等。
- 测试取消弹窗:按 `Escape` 键或点击外部背景,发现原生 Dialog 没有绑定外部点击关闭逻辑,必须向下滚动找到底部的“取消”按钮才能退出遮罩层。
2. **全局设置中心(Settings Hub)**:
- 打开齿轮图标设置面板,测试在“紧凑模式 (Compact)”与“标准模式 (Standard)”之间来回切换,网格与卡片间距自适应良好。
---
## 二、 发现的核心缺陷与不合理之处深度剖析 (Critical Issues & Bugs)
通过“浏览器实际操作 + 源码 AST/逻辑级走查”,发现以下 **4 个影响核心体验或违反技术方案** 的问题:
---
### 🚨 缺陷 1(核心技术方案违背):本地缺失模型被误判为“⛔ 身份冲突”,导致修复链路完全中断
#### 1. 问题定位
- **文件**:`api/model_resolution.py`(第 178-181 行)
- **函数**:`_resolve_from_candidates`
#### 2. 代码问题根源
在比对带有来源信息(Provenance,同时包含 `hash` 与 `size`)的模型时:
```python
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
if hash_matches:
return {"found": False, "identity_conflict": True}
return {"found": False, "identity_conflict": True} # <--- 无论 hash_matches 是否为空,均返回 identity_conflict: True!
```
- 当用户导入一个网上下载的带有哈希的工作流,而本地**压根没有这个模型**时,`candidates` 或 `size_matches` 是空的。
- 代码执行到末尾,**无条件返回了 `{"found": False, "identity_conflict": True}`**!
#### 3. 产生的不合理现象
1. **误导用户**:在前端 `ui_doctor.js` 和 `ui_recipe_model_matching.js` 中,该模型会被标上鲜红的 `⛔ 身份冲突 (Identity Conflict)`,给用户一种“本地文件损坏或文件哈希冲突被系统拒绝”的假象。
2. **阻断修复方案**:根据 `docs/architecture/model-resolution.md` 规范,只有当本地存在尺寸相同但哈希不同(或哈希相同但尺寸不同)的文件时,才算作真正的 `identity_conflict`。如果本地根本没有该模型,应该返回普通的 `{"found": False}`,以便触发正常的“未找到模型”提示、Civitai 自动检索建议或下载引导。
#### 4. 修复建议
修改 `api/model_resolution.py`,只有在发现真正的冲突(如尺寸符合但哈希不匹配、或哈希符合但尺寸矛盾)时返回 `identity_conflict: True`;无任何匹配时应返回普通未找到:
```python
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
if hash_matches:
return {"found": False, "identity_conflict": True}
return {"found": False}
```
---
### 🚨 缺陷 2(功能承诺未兑现 & 事件拦截冲突):素材库卡片“拖入画布载入工作流”无效且拖拽逻辑存在优先级漏洞
#### 1. 问题定位
- **文件 A**:`web/modules/ui_material_cards.js`(第 90-96 行)
- **文件 B**:`web/modules/material_drag.js`(第 55-84 行)
#### 2. 代码问题根源
1. **提示与实现脱节(虚假功能)**:
- 卡片上的 Tooltip 明确写着:`"按住可拖拽至画布节点注入参数,或拖至空白处载入工作流"`(`t('materialCardDragHint')`)。
- 但是在 `ui_material_cards.js` 中绑定拖拽时:
```javascript
if (material.node_types?.length) {
bindMaterialDrag(card, owner, {
payload: () => ({ ...material, node_types: [...material.node_types], dragHint: t('materialDragParameters') || '拖拽素材参数至目标节点' }),
accepts: (node, source) => source.node_types.includes(node.type),
drop: (node, source, graph) => applyLibraryMaterial(owner, source, node, graph),
// 致命缺失:根本没有传入 dropOnCanvas 回调!
});
}
```
**根本没有传入 `dropOnCanvas` 回调**!导致用户拖到空白画布释放时什么都没发生。
- 此外,如果一个素材是**整图工作流素材**(`material.node_types` 为空),因 `if (material.node_types?.length)` 为假,导致**整张卡片完全没有被绑定拖拽能力**(`draggable = false`),用户根本拖不动!
2. **`material_drag.js` 中的命中优先级缺陷(Precedence Inversion)**:
在 `material_drag.js` 的拖拽结束处理中:
```javascript
if (dropOnCanvas && overCanvas) {
try { await dropOnCanvas(event, data, graph); }
return;
}
if (!node || !accepts?.(node, data)) return;
try { await drop(node, data, graph); }
```
- 只要定义了 `dropOnCanvas`,一旦鼠标落在画布区域(`overCanvas == true`),代码会**无条件先执行 `dropOnCanvas`**(载入整图工作流覆盖画布)!
- 即便鼠标此时正精准悬停在某个兼容的节点上,也不会触发节点的参数注入!
#### 3. 修复建议
1. 在 `material_drag.js` 中调整执行优先级:**先判断是否命中了兼容节点(Target Node),若命中了则优先执行节点注入;仅当未命中任何节点且落在画布空白区域时,才触发 `dropOnCanvas` 载入工作流**。
2. 在 `ui_material_cards.js` 中,只要素材包含工作流(`material.has_workflow` 或 `material.data?.workflow`),就为其绑定 `dropOnCanvas: async (e, data, graph) => openMaterialWorkflow(owner, material.filename)`,并在 `material.node_types` 为空时也允许拖拽。
---
### ⚠️ 缺陷 3(交互体验问题):扫描向导(Scan Wizard)缺少遮罩点击关闭 & 纵向溢出
#### 1. 问题表现
- 扫描向导使用原生的 HTML `<dialog>` 挂载并使用 `showModal()`。
- 当弹窗内容较多(包含多项模型目录开关与说明)时,若屏幕纵向高度较小,底部“开始扫描”与“取消”按钮会被推到视口外部。
- 用户尝试点击周围半透明深色遮罩(Backdrop)时,无法关闭弹窗;由于未在 `dialog` 上绑定 `click` 检测 `event.target === dialog` 的关闭逻辑,用户必须手动向下滚动寻找底部取消按钮。
#### 2. 优化建议
- 在 `ui_scan_wizard.js` 中,为 Dialog 添加点击遮罩关闭逻辑:
```javascript
dialog.addEventListener('click', (e) => {
const rect = dialog.getBoundingClientRect();
const isInDialog = (rect.top <= e.clientY && e.clientY <= rect.top + rect.height
&& rect.left <= e.clientX && e.clientX <= rect.left + rect.width);
if (!isInDialog) dialog.close();
});
```
- 限制 `max-height: 85vh` 并对内部表单容器开启 `overflow-y: auto`,确保底部的操作按钮始终固定可见(Sticky Bottom)。
---
### ⚠️ 缺陷 4(同步与竞态隐患):模型单次精准扫描后前端下拉列表刷新时机
#### 1. 问题表现
- 在针对单个模型触发精准扫描(`triggerDirectModelScan`)时,后端会计算 SHA-256 并生成 `.info` 文件。
- 前端在通知扫描成功后,虽然调用了 `window.anomalous_reload_hashes()`,但在 ComfyUI 原生节点的下拉菜单选项(`widget.options.values`)重新拉取存在微弱的异步延迟,此时若立即在 Model Doctor 中点击“自动修复”,偶尔会触发 `optionsCacheStale`,需等待二次刷新才能消除红框。
#### 2. 优化建议
- 在单模型扫描写入完成后,增加 `await fetch('/anomalous/clear_cache', { method: 'POST' });` 并等待 `await app.refreshComboInNodes()` 完成后再触发 Doctor 的状态比对。
---
## 三、 总结与排查结论
1. **整体功能架构**:
插件整体架构非常先进且完整,Model Doctor、Material Library、Recipe 工作流、Scan Wizard 的多面板协调运作良好,UI 设计美观、动效细腻。
2. **核心问题收敛**:
- **核心 Bug 1**(`api/model_resolution.py` 误报 `identity_conflict`)是必须修复的高危逻辑漏洞,它直接导致工作流模型恢复方案失效;
- **核心 Bug 2**(素材库拖拽无响应及画布/节点优先级颠倒)直接影响了用户对于“素材一拖即用”的核心预期。
---
## 四、 修复复查与验收记录 (Verification Sign-off)
- **验收时间**:2026-09-19 20:30
- **修复 Commit**:`04b2eeff8f` (*fix(materials, doctor, scan): polymorphic material drag, third-party prompt nodes, model resolution, and scan wizard improvements*)
- **复查录屏文件**:`anomalous_fix_verify_1789820782702.webp`
### 1. 自动化回归测试结果
- **Python 后端单元测试**:运行 `unittest discover -s tests -p "test_*.py"`,**99 个测试全数通过 (OK)**。
- 特别验证 `test_dynamic_hash_mismatch_is_rejected` 与空候选测试,确认无哈希匹配时不再误报 `identity_conflict`。
- **前端契约与模块测试**:运行全部 32 个前端 `.mjs` 测试(涵盖 `studio_contracts.mjs`、`sidebar_feature_modules.mjs`、`material_feature_modules.mjs` 等),**32 项全部通过 (PASSED)**。
### 2. 浏览器端到端实测验证
1. **模型修复与身份冲突 (Model Doctor)**:
- 模型比对逻辑恢复正常,本地缺失模型返回 `found: False`,恢复正常下载与引导逻辑;
- 唤起模型医生点击 `🔄 刷新缓存` 顺畅运行,控制台 0 报错。
2. **素材库多态拖拽 (Polymorphic Material Drag)**:
- 全图工作流素材:卡片 Tooltip 提示更新为“按住拖至空白画布载入完整工作流”,拖拽至空白画布成功加载完整图谱;
- 提示词素材:支持拖拽至任意文本/提示词节点注入文本,或拖至空白画布自动创建对应 `CLIPTextEncode` 节点;
- 节点参数素材:拖拽至目标节点优先注入参数,拖至空白画布新建对应节点;修复了过去由于优先级颠倒导致的画布与节点拦截冲突。
3. **扫描向导 (Scan Wizard)**:
- 弹窗底部操作按钮已设置固定粘性定位(Sticky Bottom),在任何屏幕高度下均不会被挤出视口;
- 点击外部暗色遮罩 Backdrop 立即平滑关闭向导;
- 按下键盘 `Escape` 键立即响应并关闭向导。
4. **控制台与稳定性**:
- 全流程操作无任何未捕获的 JavaScript 异常或 HTTP 异常状态码。
**验收结论**:本次修复完整、精准,所有指出的严重缺陷及交互体验问题均已彻底解决并闭环。
-17
View File
@@ -1,17 +0,0 @@
# 审计与测试报告索引 (Audits & Verification Reports)
本目录专门用于归档与整理 Anomalous Model Browser 插件在各开发阶段的端到端浏览器实测、质量审计、缺陷复原以及验收记录。
---
## 报告目录归档
| 日期 | 报告文件 | 测试类型 | 涉及模块 | 验收状态 |
| :--- | :--- | :--- | :--- | :--- |
| **2026-09-19** | [AUDIT_AND_BUG_REPORT.md](file:///e:/ComfyUI_windows_portable/ComfyUI/custom_nodes/Anomalous_Model_Browser/docs/audits/AUDIT_AND_BUG_REPORT.md) | E2E 浏览器全功能实测 & 缺陷深度排查 | Model Doctor / 素材库多态拖拽 / 扫描向导 / 模型恢复 | ✅ 已全数修复并验收闭环 |
---
## 维护规范
1. 新增功能测试、跨版本兼容性体检或重大 Bug 排查报告时,请统一存放在本目录 (`docs/audits/`)。
2. 保持插件根目录整洁,避免在根目录直接堆放临时或专项测试文档。
+34
View File
@@ -115,3 +115,37 @@ Model Doctor Hash index form one mutation boundary. Local matching updates all
three through the archived full-recipe path. Partial append remaps Hash records
with node IDs and rolls them back with the inserted graph. Personal model notes
are recipe-scoped presentation data and may be excluded from package export.
## AD-016 — The audio page manages one speech engine
The audio page is built around GPT-SoVITS through the separate Anomalous_TTS
node pack: its characters, emotions, pronunciation and sampling defaults live in
the node's own settings, and the Script Director can run a script without the
canvas. The F5-TTS voice library (files under `input/F5-TTS`, upload, romanization,
Load Audio drags) was removed rather than kept as a parallel engine, so the page
teaches one set of rules. A zero-shot voice (one clip, no trained model) belongs
in Anomalous_TTS as a character kind, not as a second engine here.
## AD-017 — The browser is a rail of pages with a home page
Navigation is one narrow icon rail: a home page, the image pages (models, gallery,
workflows, materials), the audio pages (voices, audio gallery), the canvas tools and
settings. The rail replaces the header tabs, the image/audio toggle and the tool bar
under the folder list, so the list column holds only a list and can close without
hiding any command. Each page decides whether it has a list; the open state is
remembered per page and narrow windows start with it closed. The home page offers
tasks rather than a manual: each card opens its page, and a first visit lands there.
AMB stays the chore layer around ComfyUI (AD-016 keeps one engine per page); a new
page earns a rail entry only when it is a place users return to, not a one-off tool.
## AD-018 — The activity log records what Anomalous changed, without undo for now
Users need to see what the plugin did to their canvas and files. File changes are
recorded on the server, after a write request succeeds, from one table of routes,
so a new write route is logged by adding a line there. Canvas changes are recorded
without each action reporting itself: a press inside Anomalous takes a snapshot, and
the difference is logged when the user next presses or types outside it, so the
user's own edits are never attributed to the plugin. Opening another workflow is
logged as that, not as removed nodes. The log is bounded and never fails the action
it records. Undo comes later and only for changes that can be reversed safely
(canvas values, renames); deletions go through their own confirmations instead.
+226
View File
@@ -0,0 +1,226 @@
# 音频工作台使用说明
插件里也能看简版:进入音频页后,点右上角的 **!**。
---
## 0. 需要什么
音频页用 GPT-SoVITS 给角色配音,需要节点包 **Anomalous_TTS**。
- **没装时**,音频页中间会写它能做什么、怎么装。页面不会弹窗打扰你。
- **装好后**,重启 ComfyUI,再点“刷新音色”就能看到角色。
- **没装也不影响插件的其他功能。**
### 左边的侧栏
- **分组**:角色按语言分组(中文、日语、英语、未标语言)。点组名可以收起,下次打开还是收着的。只有一组时不分组。
- **展开角色**:点角色名左边的小箭头,下面会列出它的每条声音(主声音、`{开心}` 这样的情绪)。点一条就在右边播放,再点一下停。
- **拖到节点上**:侧栏里的角色可以直接拖到节点上,和拖右边卡片的标题栏一样。
- **小红点**:角色名后面有小红点,说明这个角色有问题(读取失败,或者没有能当主声音的音频)。鼠标停在点上能看到原因。
- **搜索**:角色有 6 个以上时,顶上会出现搜索框,可以搜角色名、别名或情绪名。
- **拖文件**:把音频、模型文件拖到侧栏里的某个角色上,会打开“添加文件”;拖到空白处就是导入新角色。
还没有角色的话,先看第 4 节“导入角色”。
---
## 1. 角色卡片怎么用
每个角色一张卡片:
```
┌───────────────────────────────────────────────┐
│ ⠿ 🎙 Arona 编辑情绪 读音 添加文件 3 角色音色预设 │ ← 标题栏:代表“整个角色”
├───────────────────────────────────────────────┤
│ ▶ MAIN “それぞれの学園…” [{main}] │ ← 每一行:一条参考音频
│ ▶ ANGRY “相手の雰囲気が…” [{angry}] │
│ ▶ HAPPY “うん、よし!…” [{happy}] │
└───────────────────────────────────────────────┘
```
| 操作 | 结果 |
|---|---|
| 点 ▶ | 试听这一条 |
| 点右边的 **复制 `{标签}`** 按钮(角色设了情绪才有) | 复制标签,粘贴到台词里 |
| **拖标题栏** → 放到 **角色语音 (GPT-SoVITS)** 节点 | 节点的“角色”设为这个角色 |
| **编辑情绪** / **读音** / **添加文件** | 见第 2、4 节 |
情绪靠台词里的 `{happy}` 这种标签来切换,所以拖的是整个角色,不是某一条音频。
### 拖动时看提示
拖动时,鼠标旁边有一个提示框:
- **白色**:`松手:GPT-SoVITS 节点的角色设为 Arona`。可以松手。
- **黄色**:拖到了不合适的节点,提示会写原因和正确做法。这时候松手什么也不会改。
目前只接收 **角色语音 (GPT-SoVITS)** 节点,其他节点一律不接收。这是故意的:插件不靠猜,避免写错地方。
## 2. 配音:一段话多种情绪
1. 在 **角色语音** 页顶部从 **角色** 切到 **配音**,或者在角色卡片上点 **配音**,直接带着这个角色过去。谁来念,在左边列表里点角色或在上方选。
2. 把整段台词粘贴进去,点 **分成句子**。每句话会变成一张卡片。
3. 每张卡片点一下情绪(主声音 / happy / angry……),旁边的小喇叭能听这个情绪原本的参考声音。角色只有主声音时不显示情绪按钮。也可以直接改字;鼠标移到卡片上会出现 ✂(在光标处拆开)、↧(和下一句合并)、×(删除)。
### 直接生成,不用画布
页面够宽时分成两栏:左边是台词卡片,右边是 **生成语音** 和 **念法**。窗口窄或者浏览器停靠在侧边时,生成按钮和设置放在台词下面。两种情况都只有台词这一块会滚动,句子再多,按钮也一直看得见。
1. **念法** 一直显示:语言(自动 / 中 / 日 / 英)点一下就选,语速用滑块。第一次用的是这个角色的默认值。
下面的 **细调(一般不用动)** 收着:候选数量 `top_k`、候选范围 `top_p`、随机程度 `temperature`、防重复 `repetition_penalty`,鼠标停在输入框上有说明。调过时那一行后面会写“调过”。
2. 某次效果满意,点 **把这套设置存为这个角色的默认**:语言、语速和细调都会记住,下次打开这个角色直接用。种子不存,它只对当时那段台词有意义。
3. 点 **生成语音**。插件直接交给 ComfyUI 生成,画布上的工作流一个不动;ComfyUI 正在出图时会先排队。
4. 生成完自动播放,写着“生成好了,用时几秒”。文件存在 `output/audio/角色名/`;点 **已存进音频库 ›** 直接去音频库。
5. 不满意:
- 某一句不好,点那张卡片上的 **↻**,只把这一句换一版,其他句子不变(直接用缓存,很快)。
- 整段都不好,点 **整段换一版**,换一个种子全部重新生成。
需要 Anomalous_TTS 接口版本 11 以上;旧版本时这里会提示更新,仍然可以推送到节点、在画布上运行。
### 放进自己的工作流
生成按钮下面的 **放进自己的工作流** 一栏,把剧本写进画布上的 **角色语音 (GPT-SoVITS)** 节点:
- 点 **推送到节点**:写进选中的节点。画布上只有一个这种节点时,不用选。
- 按住 **拖到节点**,把整段剧本拖到某个节点上。
- 点 **复制剧本**:复制带标签的台词,例如
```
{main} 老师,早上好!
{happy} 今天也要加油哦!
```
推送和拖动都会同时设置“角色”和“台词”,写错了可以按 Ctrl+Z 撤回。节点里原来有台词时,会先问要不要覆盖。
### 某个词总是读错
角色卡片上的 **读音** 打开这个角色的读音表:左边写剧本里的字,右边写让它实际读的字,例如 `C站 → 西站`、`粘进 → 沾进`。剧本和字幕照常写,只影响念出来的声音。
- 每一行的 **现在** 听这个角色现在怎么读左边的字,**改后** 听右边的字,对比着改。试听不会存进音频库。
- 日语模型说中文时特别有用:它发不好的音(选、全这类 ü,模型、行这类后鼻音)换成好读的同音字,或在前面加个字;英文字母写成读音相近的汉字。
- 长的先匹配,只替换一遍。只对这个角色生效。
## 3. 找回结果
- 生成的音频在图标栏的 **音频库**:
- 每条会显示台词、声音和种子,可以试听、按台词搜索、删除。
- 只在预览里出现、还没保存的临时结果,列在“未保存的预览”里,点 **保存** 就能存到 `output/audio/`。
## 4. 导入角色和设置
### 设置
工具栏上有 **导入角色**。还没有角色时,中间是一块空的导入区,直接把文件拖进去或点 **+ 导入角色**。
存放位置、底模这些设置在左边侧栏的最下面:**GPT-SoVITS 设置**,点开是一个窗口。底模缺了不会挡路(第一次生成时会自动下载),所以只在这个入口上放一个小黄点:已有角色用到的底模还没下载时才出现。窗口里第一行写着缺几个、多大,可以 **现在下载**,也可以点 **不再提醒**。以后导入了要用新底模的角色(比如第一个中文角色),小黄点会再出现。缺 Python 包时是小红点,不能关掉。下载的进度也写在这个入口下面。
窗口里有:
- **角色存放位置**:导入的角色都放在这里,默认 `ComfyUI/models/gpt_sovits/`。
- **其他位置**:设置文件里写的其他角色文件夹,以及 `extra_model_paths.yaml` 里的文件夹。
- **底模**:每个底模是已就绪、缺少还是下载中。可以单个 **下载** 或 **全部下载**。只说日语的角色不需要中文底模;标“可选”的缺了也能用。
- **Python 包**:缺了哪种语言要的包,会给出安装命令,复制到命令行运行后重启 ComfyUI。
- **设置文件**:Anomalous_TTS 的设置文件 `ComfyUI/user/anomalous_tts.json` 的位置,可以复制路径。存放位置、其他角色文件夹和已有的 GPT-SoVITS 整合包(用包里的底模,不用再下一遍)都在这个文件里改,窗口里改不了:节点不接受网页指定的本机路径,这样网页就决定不了它在哪里建、删文件夹或读哪些文件。路径写完整,改完不用重启:
```json
{
"format": 1,
"storage": "D:/voices",
"libraries": ["E:/old voices"],
"pretrained": ["D:/GPT-SoVITS/GPT_SoVITS"]
}
```
要换存放位置,先把角色文件夹自己移过去,再改 `storage`。
导入表单里选了语言后,下面会用小字写出生成时还要用到哪些底模。
这些操作会读写运行 ComfyUI 那台电脑上的文件,所以只能在那台电脑上用;从别的设备打开时按钮是灰的。
### 导入角色(新用户从这里开始)
点 **导入角色**。第一次打开时会有一段遮罩引导,一步步指给你看;之后随时点窗口右上角的 **?** 再看一遍。窗口顶上写着现在在哪一步:**① 选方式 → ② 放入文件 → ③ 导入**。
**① 选方式**:窗口先问“你手上有什么?”
- **一个角色**:这个角色的 GPT 模型、SoVITS 模型和几段参考音频。会一步步带你放好。
- **很多角色 / 整合包**:一次导入好几个角色,或者选一个 GPT-SoVITS 整合包文件夹,会按名字自动分好。
- **给已有角色加声音**:选一个已经导入的角色,往里面加参考音频、台词文件或新模型。
直接把文件拖到音频页或这个窗口上,会跳过这一步,按“很多角色”处理(一个角色的文件也能正确认出来)。选错了可以点 **‹ 换一种方式**(还没放文件时才有)。
**② 放入文件**:每个角色一张卡片,卡片最上面是一份**待办清单**,永远高亮下一步,按钮就在那一行:
1. 放 GPT 模型(`.ckpt`,在整合包的 `GPT_weights` 文件夹里)
2. 放 SoVITS 模型(`.pth`,在 `SoVITS_weights` 文件夹里)
3. 放几段参考音频(3~10 秒、说得清楚的)
4. 选一段当主声音:在音频列表里点 **☆**,变成 **★** 就是主声音(一般会自动选好)
5. 给角色起个名字(一般会从模型文件名自动填好;卡片标题就是名字,可以直接改)
做完一项就打勾;卡片标题旁写着“还差 N 步”,全部完成变成绿色的 **可以导入**。任何时候都可以直接把文件拖进窗口,会自动放到对的地方。
参考音频一段一行:点前面的 **▶** 试听,再点一次(■)停止;点另一段就直接换过去,方便一段段对比着挑。上传中、还没分给角色的音频都能听。填上情绪名(例如“开心”)就成了 `{开心}`。**不在 3~10 秒的音频不会导入**(节点用不了它们),连同同名的台词文件一起收进卡片底部的“不导入”,点开能看到是哪些。一个角色音频很多时先显示前 60 段,点 **显示全部** 看其余的。台词会自动从同名 `.txt` / `.lab` 或标注文件里找;台词框是黄框表示台词取自文件名,请确认;红色虚线框表示没找到,可以粘贴,也可以把 `.txt` / `.lab` / `.list` 拖到框里。卡片底部是台词文件和 **角色说的语言**(一般自动判断就行)。
**很多角色 / 整合包**时:
- **一个文件夹一个角色**:一个文件夹里(包括它的子文件夹)只有一套 GPT + SoVITS 模型,还有音频或台词,它就是一个角色,用文件夹名当角色名。比如 `派蒙/` 里有 `model-e10.ckpt`、`model_e10.pth` 和参考音频,角色就叫“派蒙”;两个角色的模型都叫 `model` 也不会混在一起。文件夹叫 `v2`、`v4` 这类版本名时,前面加上上一级的名字(“派蒙 v2”)。
- 模型放在整合包的 `GPT_weights*` / `SoVITS_weights*` 里,或者好几个角色的模型平铺在一起时,按模型文件名配成角色:`ALuoNa-e15.ckpt` 和 `ALuoNa_e16_s224.pth` 都算 `ALuoNa`。好几轮(e5、e10、e15)时自动用轮数最大的。
- 其他的按所在的文件夹(同一个文件夹里的模型)、文件夹名(比如 `参考音频/ALuoNa/`)或文件名开头分给对应的角色。同一个文件夹里有 `Guang` 和 `Guang_v4` 两套模型时,归 `Guang`。
- 没分给任何角色的 `.list` 标注文件,每个角色都放一份(节点按文件名找台词,不会找错)。
- 同一个角色里有同名的文件(比如 `开心/01.wav` 和 `难过/01.wav`),后来的会在前面加上文件夹名(`难过_01.wav`),同名的台词文件一起改,不会丢;鼠标停在文件名上能看到原来的文件。
- 分不出来的放在最上面一张黄色卡片里,放进角色后才上传。文件多时一个文件夹一行,可以把整个文件夹一次放到某个角色;点开这一行,也能一个一个选。分错的文件,右边也能改。
- 不会读的文件夹:`runtime`、`pretrained_models`、`venv` 这类 Python 环境和底模文件夹;整合包里(旁边有 `runtime`、`GPT_weights*`、`webui.py` 这些)的 `GPT_SoVITS`、`logs`、`output`、`TEMP`、`tools`,里面是程序、训练中间存档和数据集切片。你自己起名叫 `output`、`GPT_SoVITS` 的文件夹照常读;你直接选中或拖进来的那个文件夹一定会读。跳过了哪些,窗口里会写出来。
- 一次最多读 5000 个文件。
- 角色很多时先打开一批(20 个角色,或者大约 800 个文件),其余的在“打开下一批”按钮里;这一批导入完,下一批会自动打开。这样一次拖进很大的文件夹也不会卡。
- 一次要上传很多文件(超过 1500 个或 2 GB)时会先问你要不要继续。文件都是从浏览器上传给节点的,节点不会按路径去读你电脑上的文件。
- 卡片收起时只有一行,点开看详情,一次只展开一张。右上角 **+ 继续添加** 可以再加文件,**+ 新角色** 手动加一个角色。
**③ 导入**:底部按钮写着“导入 N 个角色”,只导入“可以导入”的那些。每个角色单独导入,一个失败不影响其他的;失败的留在窗口里标红,改好再点一次。全部导入完窗口自动关闭,并滚到新角色的卡片。底部还写着会保存到哪里(想换地方,在设置里改存放位置)。
节点会建好 `角色名/GPT_weights/`、`SoVITS_weights/`、`参考音频/` 和设置文件。**一律复制,原文件不动**;中途出错时存放位置保持原样,不会留下半个角色。
给已有角色加音频或情绪:点卡片上的 **添加文件**,或者把文件拖到那张卡片(或侧栏里的角色名)上。这时放进来的文件都归这个角色,模型可以不放。已有的文件不会被覆盖,主声音也不会被换掉(除非你点了“★ 主声音”)。
一条一条加也不会越加越乱:
- 角色里已经有一模一样的文件,那一行是灰色的“角色里已有,跳过”,不会再复制一份。
- 标注文件(`.list` 等)再放一次,只会把新的行追加进角色里已有的那份,写着“合并新台词”。
- 只加一条音频、不带标注文件时,台词会从角色里已有的标注文件里找。
- 只有“同名但内容不同”的文件会标红,不能导入。改个文件名,或者删掉那一行。
### 角色文件夹的规则
- 一个角色就是一个文件夹,里面至少有一个 `.ckpt`(GPT 权重)和一个 `.pth`(SoVITS 权重)。
- 角色文件夹放在存放位置里(默认 `ComfyUI/models/gpt_sovits/`),也可以放在 `extra_model_paths.yaml` 里写的 `gpt_sovits` 目录。自己往存放位置里放整理好的角色文件夹也可以,点 **刷新音色** 就能看到。
- 同一个角色有日配、中配两个版本时,会显示成 `阿罗娜/日配`、`阿罗娜/中配` 两个角色。
### 编辑情绪
点角色卡片上的 **编辑情绪**:
1. **主声音 `{main}`**:选一条 3~10 秒的参考音频。留空的话,节点会自己挑一条。
2. **情绪**:每行填一个情绪名(例如“开心”),再从角色文件夹里选一条参考音频。输入框可以直接打字搜索文件名。
3. **参考台词**:可以不填,节点会自己去找同名 `.txt` 或 GPT-SoVITS 的标注文件。
4. 点 **保存**。设置会写进角色文件夹里的 `anomalous_tts.json`,由节点检查后保存。选了不存在的文件时,会提示原因,什么都不会改。
文件名本身带情绪的音频(例如 `Arona_Talk.开心.wav`)会被自动识别,不用再填一遍。只是扩展名的部分不算情绪,例如 `Arona_Talk.ogg.wav`、`Arona_Talk.ogg (1).ogg`。
角色列表会缓存一会儿,切换页面不会卡。往模型文件夹里加了角色或音频后,点 **刷新音色** 才会重新读取。
### 台词里的其他写法
- `[普拉娜]`:换人说。要写角色名或别名。
- `[pause:1s]`:停顿。
## 5. 常见问题
| 现象 | 原因和解决 |
|---|---|
| 拖到节点上提示“列表里还没有 …” | ComfyUI 还没刷新节点定义。按 **R** 后再拖 |
| 标题栏拖不动 | 这个角色没有主声音 |
| 拖到别的语音节点被拒绝 | 目前只支持“角色语音 (GPT-SoVITS)”节点 |
| 卡片上提示“没有找到合适的主声音” | 角色文件夹里没有 3~10 秒、找得到台词的音频。点“编辑情绪”手动选一条 |
| 新放进去的角色或音频没出现 | 点 **刷新音色**。节点那边在 ComfyUI 里按 **R** 刷新下拉框 |
| 侧栏里看不到“GPT-SoVITS 设置”,工具栏上没有“导入角色” | Anomalous_TTS 版本太旧(没有接口版本 3)。更新 Anomalous_TTS 后重启 ComfyUI |
| 同名文件导入时报“有重名的文件” | Anomalous_TTS 早于接口版本 9。更新后重启 ComfyUI |
| “导入角色”是灰的 | 页面是从别的设备打开的。导入会写文件,只能在运行 ComfyUI 的电脑上做 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

-248
View File
@@ -1,248 +0,0 @@
# 代码结构拆分计划(交给 GPT-5.6-sol)
日期:2026-09-17。代码基线:`29b14d2c7`。**状态:阶段 0–7 已完成;自动化验收通过,真实 ComfyUI 宿主视觉/交互验收仍需在运行中的宿主完成。**
用户当前优先级是整理代码结构,暂停其他 UI 美化与功能扩展。本文保留原实施任务单,并在末尾记录实际完成结果;当前架构以专题文档和代码为准。
## 1. 要达到的结果
将大文件按实际职责拆开,做到修改一个页面或一项行为时,不必理解整个浏览器。保留现有界面、操作、接口和用户数据。每完成一个可验证的小阶段就本地提交,继续下一阶段,不必逐项询问用户。
不是把大文件切成 `part1/part2`,也不是迁移到新框架。不得为拆分引入打包系统、事件总线、通用服务容器或新依赖。
阅读顺序:[AGENTS.md](../../AGENTS.md) → [架构入口](../../ARCHITECTURE.md) → 当前阶段对应的专题。不要每轮通读所有文档。本文的文件名是拟定归属,先搜索是否已有等价模块;存在时优先复用。
## 2. 当前规模与真正的拆分点
以下是含空行的物理行数,仅用于定位;执行时按函数和调用方搜索,不按固定行号切割。
| 文件 | 行数 | 主要问题 |
| --- | ---: | --- |
| `web/styles.css` | 14,620 | 多个页面、主题和响应式规则混在一起,层叠顺序容易被破坏 |
| `web/modules/ui_recipe_detail.js` | 3,348 | 总览、模型匹配、参数编辑、版本、图库集中在一个模块 |
| `web/modules/ui_sidebar.js` | 2,963 | `createDOM()` 约 2,250 行,包含扫描向导、设置、工具箱、导航和窗口交互 |
| `web/modules/ui_doctor.js` | 2,186 | 模型诊断、节点助手、替换图库、参数方案交叉放置 |
| `web/modules/ui_detail.js` | 1,938 | 模型详情、编辑弹窗、高级选择器合并 |
| `api/recipes.py` | 1,514 | 工作流规范化、身份信息、图片、文件存储、历史和 HTTP 路由混合 |
| `web/modules/ui_gallery_detail.js` | 1,453 | 图片工作台生命周期、媒体交互、参数检查与保存操作集中 |
| `web/main.js` / `api/models.py` | 各 1,413 | 前者注册、设置、实例、浮动入口耦合;后者目录、媒体、修改、匹配路由耦合 |
| `web/modules/ui_recipes.js` | 1,297 | 列表筛选、卡片、保存/编辑对话框与保存流程混合 |
| `api/materials.py` | 1,294 | 素材构建、提示词规则、资源存储、查询、路由混合 |
| `web/modules/ui_materials.js` | 1,186 | 目录、详情、节点选择监听、应用操作混合 |
| `web/modules/ui_notebooks.js` | 1,042 | 工作台返回、笔记目录、编辑器和节点创建混合 |
`locales.js` 为 2,373 行,主体是字典,不优先拆。已有提示词工坊、来源中控等模块虽然仍较大,已有明确边界,放到后续复核,避免第一轮同时动所有系统。
**已确认的耦合:**
- `main.js` 通过 `AnomalousBrowser.prototype` 接入各 UI 模块。首轮保留方法名、参数和 `this` 绑定,只调整导入归属;不要同时更换整个实例模式。
- `ui_sidebar.js` 的扫描状态、语言刷新、按钮和导航大量依赖 `createDOM()` 闭包。移动函数前必须列出它读取、修改的外部变量。
- `api/materials.py` 从 `recipes.py` 导入了 12 个名称;`parameters.py` 也导入其中的参数签名、读取与图库辅助函数。后端不能把每份文件单独移动后再补循环导入。
- `ui_gallery_detail.js` 有模块级 `wb` 和元数据缓存。它们不能在多个新文件各复制一份。
- `api/__init__.py` 目前用星号导入路由。最后接线时应改成明确的模块引用,防止移动后隐式导出丢失或名称覆盖。
## 3. 拆分时保持的边界
依赖方向采用现有结构:**宿主接线 → 页面协调与视图 → 领域逻辑/存储与已有公共工具**。纯逻辑不能反向导入页面,子视图不能导入 `main.js` 获取实例。
- 每项状态只保留一份。已有跨页返回状态先留在当前 owner;局部 DOM、请求和清理归所属视图。迁移状态和移动代码分开提交,避免难以区分故障来源。
- 为实际功能传入必要容器、数据和回调。若拆分需要几十个闭包参数,说明边界选错,应把相关状态和行为一起归入该功能模块。
- 模块接入时直接修改所有内部调用方。只有确有外部调用或兼容需求的入口才保留薄转接,不建立永久的重复实现。
- 同一阶段保持路由 URL、请求/响应结构、文件路径、字段、存储锁、撤销行为、节点连接与参数写入语义不变。不迁移用户数据。
- 复用 `ui_lifecycle.js`、`ui_dom.js`、`recipe_actions.js`、`recipe_parser.js`、`recipe_identity.js`、`node_material_actions.js` 等现有能力,不再复制一套。
- 普通逻辑模块通常以 200–600 行为参考。确有多个职责才继续拆,不能靠压缩代码、删注释或空壳包装达标。语言表和样式表不套这个阈值。
- 文件改放子目录时重新计算相对导入,特别是 ComfyUI 的 `scripts/app.js` 和图片 URL。导入无副作用的模块不应注册第二个扩展、启动扫描或立即创建页面。
## 4. 分阶段执行
每个阶段允许拆成几个提交;一个提交完成一个可验证的职责迁移。不要一次把以下所有文件建好再尝试运行。
### 阶段 0:建立本轮基线
1. 查看状态和最新差异,记录起始提交;保留他人的改动。
2. 为即将移动的职责记录:入口/调用方、读写状态、全局监听、请求、DOM/CSS 依赖、关闭路径。只写本阶段,不建立全仓超大清单。
3. 先运行本阶段已有的相关测试,记录已有失败。需要补测试时优先覆盖真实行为,而不是源码中是否存在某个字符串。
4. 记录相关页面的当前视觉和交互。后续比较必须使用同一主题、窗口尺寸和数据;不拿不同时期截图作基线。
### 阶段 1:先从侧栏移走独立功能
目标文件:`ui_sidebar.js`。第一刀选扫描向导,不先重写导航。
| 拟建模块 | 迁移范围与接口 |
| --- | --- |
| `ui_scan_wizard.js` | `createWizardModal()` 及其专属构建/扫描控制;接收全局或指定模型范围与完成回调,返回打开、关闭与语言刷新所需接口 |
| `ui_folder_manager.js` | `openFolderManager()` 与该对话框专属请求、DOM、关闭逻辑;原来的目录浏览 `renderSidebar/loadFolders` 暂时保留 |
| `ui_help.js` | `showHelp()` 及内容构建,仅在打开时创建所需内容 |
扫描进度继续使用 `scan_progress.js`,不能新增另一份“是否正在扫描”。若该状态也被全局按钮使用,明确由扫描功能拥有,按钮只读取/订阅实际状态。关闭向导是否取消扫描必须保持当前语义,不能把 UI 关闭误当成后台任务取消。
验收:扫描向导打开/关闭、单模型与全局入口、失败反馈、文件夹管理取消与保存刷新、帮助关闭。涉及扫描或写文件的验证优先使用隔离数据与桩服务,不自动扫描用户整个模型库。
### 阶段 2:收拢外壳和入口
在阶段 1 稳定后,从 `createDOM()` 提取:
- `ui_settings_hub.js`:设置中心与模型显示设置。接口覆盖 open/close/refreshLanguage,保留原来的设置来源、键名和生效时机。
- `ui_toolbox.js`:工具箱内容、开关和底部按钮装配;复用 `tool_registry.js`、`shortcut_layout.js`、`sidebar_actions.js`、`ui_shortcut_organizer.js`。按**当前代码**保持固定栏与工具过滤规则,不顺便实施旧计划中的另一种交互。
- `ui_browser_navigation.js`:主面板切换与返回协调。先收拢现有 `hideAllPanels/closeWorkspace` 相关调用,不引入新的路由系统;笔记模块最终不再独占其他工作台的公共关闭逻辑。
- 窗口拖动、缩放、停靠先保留在外壳;仍形成独立大段时再提取 `browser_window.js`,复用生命周期工具,不改变尺寸偏好。
`createDOM()` 最终只创建骨架、装配功能、连接明确回调。初始化完所有被引用的控件后再绑定/刷新语言,避免迁移后出现 TDZ 或读取尚未初始化的按钮。
随后整理 `main.js`:浏览器类与方法接线放到 `browser.js`,浮动入口放到 `browser_entry.js`,设置描述与语言/主题应用放到 `interface_settings.js`。扩展注册、实例创建顺序和宿主钩子仍由 `main.js` 统一协调。设置模块通过参数/回调工作,不能反向导入 main;保留被其他模块调用的现有全局入口,先核实调用者再删除。
验收:首次打开、关闭重开、各主页面切换、配方→模型详情→配方、语言切换、设置保存、工具箱提示、停靠、窗口拖动与调整尺寸。确认只有一个浏览器实例和一份全局监听。
### 阶段 3:拆配方详情,再拆目录
`ui_recipe_detail.js` 留下 `showRecipeDetail()`、活动页签、一次详情会话的状态与生命周期协调。按下面顺序逐块移动:
1. `ui_recipe_versions.js`:`renderVersions/renderDiffPanel`。继续用 `recipe_diff.js`;恢复版本后的刷新由明确回调交回详情协调器。
2. `ui_recipe_gallery.js`:`renderRecipeGallery/showGalleryComparison` 及专属资源处理;图片详情打开通过回调或直接依赖现有图片工作台入口,不能绕素材目录模块中转。
3. `recipe_model_matching.js` + `ui_recipe_models.js`:前者负责匹配操作与结果,后者负责组成卡片、来源编辑和本地匹配操作界面;复用身份规则,不能从名称/预览图推断同一模型。
4. `recipe_parameter_data.js` + `ui_recipe_parameters.js`:先移出 `parameterNodeOrder/topologicalSortNodes/parseEditorValue/paramsWithPromptRole` 等无 DOM 逻辑,再移参数方案 UI。较大的原始节点检查与编辑器可分别放 `ui_recipe_node_inspector.js`、`ui_recipe_parameter_editor.js`。
5. `ui_recipe_overview.js`:总览与元数据编辑,参数正文读取继续遵循真实工作流,不改用列表摘要。
不要把所有小函数扔进 `recipe_detail_utils.js`。格式化工具仅在确有多个调用者时共享;请求成功后的刷新由协调器处理,不让子页互相直接调用重绘。
接着将 `ui_recipes.js` 的 `showRecipeSaveDialog/showRecipeEditDialog` 放到 `ui_recipe_dialogs.js`,卡片渲染放 `ui_recipe_cards.js`;列表查询、筛选、分页与页面状态留在目录模块。`handleSaveRecipe` 继续协调实际捕获和保存,不能在新对话框中复制该流程。
验收:配方筛选与详情、长提示词、原始参数、参数方案保存再读回、模型详情往返、历史比较/恢复、图库打开/关闭。配方包导入导出保持当前关闭状态,移除的入口不恢复。
### 阶段 4:拆其余混合页面
逐行执行下表,每行独立提交与验证,禁止把全部页面同时改成半成品。
| 当前文件 | 拟定归属 | 重点验证 |
| --- | --- | --- |
| `ui_doctor.js` | 保留诊断面板和全局扫描;节点助手到 `ui_node_assistant.js`;`_openGalleryReplacer` 到 `ui_node_model_picker.js`;参数方案卡片与预览到 `ui_node_presets.js` | 哈希依据不变;替换正确节点;取消无写入;助手历史可用 |
| `ui_detail.js` | 保留 `showDetail`;`showEditModal` 到 `ui_model_editor.js`;`_openAdvancedModelSelector` 到 `ui_model_selector.js` | 详情往返、编辑保存、选择器取消、原节点与路径不串 |
| `ui_gallery_detail.js` | 保留工作台打开/关闭与唯一 `wb`;舞台缩放/胶片栏到 `ui_image_stage.js`;参数、提示词、模型与节点检查到 `ui_image_inspector.js` | 快速换图无旧结果覆盖;关闭释放媒体与请求;选中块保存准确 |
| `ui_materials.js` | 保留目录与分页;详情到 `ui_material_detail.js`;卡片到 `ui_material_cards.js`;应用和选择监听尽量归入已有 `ui_material_application.js` | 素材拖放仍整体替换;节点身份复核;详情读正文;翻页与筛选不丢 |
| `ui_notebooks.js` | 保留笔记目录与保存协调;编辑器到 `ui_notebook_editor.js`;画布创建到 `notebook_canvas.js`;公共返回已归阶段 2 | 保存队列和重开不丢内容;正负提示词隔离;节点创建与连接不变 |
图片元数据缓存可以留在协调模块,缓存上限和失效规则保持不变。需要跨模块使用时暴露窄访问接口,不复制缓存,不把 `wb` 作为可随意修改的全局对象导出。
### 阶段 5:样式独立拆分,不同时重新设计
保留 `web/styles.css` 作为外部入口。先核对 `main.js` 的 CSS 加载逻辑,再把样式拆到 `web/styles/` 下的基础控件、外壳、模型、图库、配方、素材、提示词、工具与主题等文件。
**首先保证原始层叠顺序。** 不能按关键词把分散的同名选择器直接合并搬走。先记录规则顺序与主题/媒体条件;需要时按连续职责块分两步迁移,稳定后再清理重复声明。混合多个领域的选择器先归公共部分,未经确认不拆成几个不同位置的规则。
低风险方案是入口顶部有序 `@import`。若使用多 link,也必须只有一份确定顺序的清单,并保留防重复加载。二选一,不能双重加载。不要引入 CSS cascade layers,因为它会改变已有优先级。
迁移时检查相对 `url(...)` 资源路径、`@keyframes`、主题限定、媒体查询和插件作用域;子样式的缓存刷新也要验证,不能只刷新入口版本。检查网络中是否缺文件、串行请求造成明显闪烁。没有测试依据,不顺便做按页面动态加载。
验收:同条件截图/关键计算样式对比,普通主题与深海血族、普通窗口与停靠、浮层层级、文件夹隐藏/恢复、提示定位、拖放与键盘焦点。此阶段不换配色、间距、动画或 DOM 结构。
### 阶段 6:先拆后端共享领域,再移动路由
先读 [后端专题](../architecture/backend.md)、[配方专题](../architecture/recipes.md)、[素材专题](../architecture/material-library.md)。
1. 从 `recipes.py` 提取 `workflow_schema.py`:工作流校验、指纹、参数签名、易变字段规则等纯计算。确属配方专有的规范化放 `recipe_schema.py`;不要把文件路径和 HTTP request 带入纯逻辑。
2. 提取 `recipe_store.py`:目录、读取、写入与历史操作;提取 `recipe_images.py`:来源图片验证、读取与封面处理。路径授权仍调用现有安全工具,I/O 仍放到原来的工作线程边界。
3. 同步改 `materials.py`、`parameters.py` 和 `recipe_packages.py` 的调用。后者目前是 `from . import recipes as recipe_store`,这个别名指向旧路由模块,不是拟建的存储模块。逐项检查其 `recipe_store.xxx` 访问,不能只搜索导入行或直接把别名换成新文件。
4. 路由模块依赖领域与存储模块,领域模块不得反向导入路由。锁和缓存只保留在实际拥有者中,同一类写入不能因拆文件变成两把独立锁。
5. `api/recipes.py` 最终保留 HTTP 参数解析、错误/状态码映射、调用和响应。不要为了“薄路由”把整个旧文件原封不动搬进另一个 1,500 行的 service。
随后拆 `api/models.py`:目录查询/列表、身份解析、元数据修改、媒体与封面分别归入 `model_catalog.py`、`model_resolution.py`、`model_metadata.py`、`model_media.py`。先按现有端点边界移动,公共路径与类别规则继续复用 `utils.py` 和根目录的 `model_policies.py/model_identity.py`。保留跨目录回退和受保护类别规则。
最后拆 `api/materials.py`:`material_schema.py` 负责构建与提示词规范化,`material_store.py` 负责记录及资源生命周期,路由模块负责请求响应和有界查询。若存储模块仍大,再将图片资产操作独立成 `material_assets.py`,不预先造空模块。
`api/__init__.py` 改成明确的模块引用和注册,例如 `from . import models`、`models.api_get_models`;比较拆分前后的 **HTTP method + path** 清单,避免少注册、多注册或重复注册。用户设置和导入导出 gate 保持不变。
验收全部使用临时目录与隔离配置:保存/读取往返、非法路径拒绝、失败保留旧文件、历史恢复、素材分页过滤、修改封面/元数据、禁用导入导出的 503。不能通过删除或覆盖用户真实数据测试。
### 阶段 7:收尾与剩余热点复核
- 再统计大文件,按职责复核 `ui_prompt_workbench.js`、`ui_prompt_source_deck.js`、`ui_model_sources.js`、`material_inspector.js` 和 `api/utils.py`。有独立职责才继续拆,并记录边界;不能仅因超过阈值机械拆分。
- 复核导入环、无人调用的旧导出、遗留重复实现和 `EXTRACTED` 占位。不要用任意 optional chaining 或空 catch 掩盖断掉的接线。
- 更新实际发生变化的架构专题与入口所有者列表。此时删除过时说明,不能把本文的拟定方案提前复制成已实现架构。
- 本计划逐阶段补充提交号、验证和剩余事项;发生中断时,下一轮从这里继续,不重新扫描整个仓库。
## 5. 验证与效率要求
每阶段先做 JS 语法/模块链接或 Python 编译/导入检查,再跑相关行为测试。涉及界面和宿主行为时还需真实 ComfyUI 验证,语法通过不能代替点击路径。移动模块后更新测试入口,尽量加载实际模块,不把待测行为本身 mock 成成功。
当前存在的定向检查(从插件根目录运行)。下表每个 JS 文件分别用 `node --experimental-vm-modules <文件路径>` 执行;此处参数表示替换成表内实际路径,不是一次执行整表:
| 影响范围 | 现有检查 |
| --- | --- |
| 外壳/快捷栏 | `tests/shortcut_layout.mjs`、`tests/shortcut_organizer.mjs`、`tests/update_guide.mjs` |
| 素材与节点应用 | `tests/material_workspace_flow.mjs`、`tests/studio_contracts.mjs` |
| 笔记 | `tests/notebook_render_test.mjs`、`tests/notebook_save_roundtrip.mjs` |
| 提示词工坊/读取 | `tests/prompt_ui_lifecycle.mjs`、`tests/prompt_material_source.mjs`、`tests/prompt_library_sync.mjs` |
| 模型来源 | `tests/model_sources_hub.mjs` |
| 导出 gate | `tests/export_availability.mjs`;Python 用便携环境执行 `python -B tests/test_export_availability.py` |
表中测试存在不等于本轮已经通过。配方详情、模型编辑、扫描向导、后端存储等未被这些测试充分覆盖的路径,需要有针对性地补行为验证,不能拿无关测试通过数充当覆盖。
新增测试先核对 `.gitignore`,必须明确哪些随仓库交付。每次只跑受影响的集合;阶段完成后不重复跑同一组,除非又改了代码或发现新问题。
完成标准:原调用方都接入新归属;原文件没有第二份实现;关键行为验证通过;入口和返回仍可用;无新增持续轮询、全量数据加载或监听泄漏。行数下降本身不是验收标准。
## 6. 交给 GPT-5.6-sol 的启动指令
> 请按 docs/plans/code-structure-refactor.md 整理 Anomalous_Model_Browser 的代码结构。先读 AGENTS.md 和相关架构专题,确认最新差异,从阶段 0、阶段 1 开始,然后按依赖顺序继续。保留现有外观、功能、数据和接口,不顺手美化,不恢复已关闭或移除的功能。
>
> 每次完成一个职责迁移,检查调用方、状态归属、闭包依赖和关闭路径,运行相关验证后本地提交,再继续。常规实现选择自行处理;遇到已有故障先区分基线与回归,不能隐藏或伪造成功。不要把所有文件一次拆完才验证。
>
> 每阶段在计划中记录实际模块归属、提交号、验证结果与未完成项。需要中断时留下可接续状态;不推送远端。最终说明职责如何变清楚、哪些行为已验证,以及还有哪些真实限制。
## 执行记录
- 阶段 0(完成):以 `c39701773` 为本轮起点,开工时工作区干净。基线通过
`shortcut_layout.mjs`、`shortcut_organizer.mjs`、`update_guide.mjs`;未建立真实
ComfyUI 点击截图基线,因此后续不能把 DOM 模拟检查称为宿主视觉验收。
- 阶段 1(完成,`2a53e3edf`):扫描向导归 `ui_scan_wizard.js`,文件夹管理归
`ui_folder_manager.js`,帮助弹窗归 `ui_help.js`。单模型卡片与全局扫描入口共用
`openScanWizard()`;新增行为检查覆盖帮助关闭、文件夹取消/保存刷新、单模型扫描请求
与向导关闭不取消后台扫描。
- 阶段 2(代码拆分完成,`1e42033ac`、`67d5be6277`、`a984ee4a31`):设置中心与模型卡显示设置归
`ui_settings_hub.js`,工具目录、固定快捷栏和工具分派归 `ui_toolbox.js`,公共面板
隐藏/配方详情清理归 `ui_browser_navigation.js`。`ui_sidebar.js` 已从约 2,963 行降至
约 780 行,`createDOM()` 现在主要保留外壳、顶部导航、窗口交互和子模块装配。
浏览器类和方法装配归 `browser.js`,浮动/顶栏/菜单入口及唯一实例归
`browser_entry.js`,语言和主题设置归 `interface_settings.js`;`main.js` 现只协调
扩展注册与宿主钩子。笔记、配方和素材共用的工作区返回协调也已迁入
`ui_browser_navigation.js`。新增模块边界测试覆盖注册描述、入口设置、语言、主题与
工作区恢复;真实 ComfyUI 拖动、停靠与窗口缩放仍待宿主验收。
- 阶段 3(代码拆分完成,`d8691d881`、`413b876dfe`、`56774a2d99`、`881570e887`、`e5f3301971`):配方版本历史/差异/恢复归
`ui_recipe_versions.js`,结果图库与图片工作台跳转归 `ui_recipe_gallery.js`,跨子页
共用的 DOM、长值展示和复制控件归 `ui_recipe_detail_dom.js`。删除了无人调用的旧图库
对比实现;图库不再借道素材目录模块打开图片工作台。目录筛选归
`ui_recipe_catalog.js`,卡片与拖拽归 `ui_recipe_cards.js`,保存/编辑对话框归
`ui_recipe_dialogs.js`,封面媒体归 `ui_recipe_media.js`;模型预览、匹配与显式替换归
`ui_recipe_model_matching.js`,总览归 `ui_recipe_overview.js`,提示词角色、参数编辑、原始节点与
参数预设归 `ui_recipe_parameters.js`,内联持久化归 `ui_recipe_metadata.js`。协调器
`ui_recipe_detail.js` 从约 2,983 行降至约 760 行,`ui_recipes.js` 从约 1,297 行降至约
690 行。新增检查覆盖版本恢复、图库交接、目录卡片隔离、模型替换持久化、参数拓扑/解析、
总览和提示词提取;真实 ComfyUI 视觉与交互仍待宿主验收。
- 阶段 4(完成,`e6217bb557`、`12875f9053`、`3e3e26d2b2`、`2951d610c5`、`7afa7518bc`):
模型诊断保留诊断、全局扫描和哈希协调,助手、原生控件选择器、参数方案分别归
`ui_node_assistant.js`、`ui_node_model_picker.js`、`ui_node_presets.js`。模型详情保留展示协调,
编辑器与高级选择器归 `ui_model_editor.js`、`ui_model_selector.js`。图片工作台仍唯一持有 `wb`
和元数据缓存,舞台/缩放/胶片条归 `ui_image_stage.js`,检查器标签归 `ui_image_inspector.js`。
素材目录、卡片、详情与节点应用/监听分别归 `ui_materials.js`、`ui_material_cards.js`、
`ui_material_detail.js`、`ui_material_application.js`。笔记目录与保存留在 `ui_notebooks.js`,
编辑器归 `ui_notebook_editor.js`,画布节点创建归 `notebook_canvas.js`。新增边界与行为检查覆盖
以上接线、监听器清理、控件路径、素材应用和笔记保存队列;真实 ComfyUI 视觉仍待宿主验收。
- 阶段 5(完成,`e8b50ecaf5`):`web/styles.css` 保留为唯一外部入口,并按原始字节顺序有序导入
`web/styles/00-foundation-models.css` 至 `10-model-sources.css`。所有子样式带同一缓存版本;
`css_bundle_order.mjs` 校验导入唯一/有序,且子文件拼接 SHA-256 与拆分前完全一致
(`d014a9d1826e2c48a46ac72389fe4e5bdd0b2ba85618f653d13852cd93812bc1`)。
未引入 cascade layer、动态加载或视觉改版;真实主题、停靠和浮层对比仍需宿主验收。
- 阶段 6(完成,`6ad975c1cd`、`9cebdbd994`、`a0c6849878`):配方的工作流规则、配方规范化、
图片和存储分别归 `workflow_schema.py`、`recipe_schema.py`、`recipe_images.py`、`recipe_store.py`;
模型目录、身份解析、元数据写入和媒体归 `model_catalog.py`、`model_resolution.py`、
`model_metadata.py`、`model_media.py`;素材规范化、资产和单一锁/缓存存储归
`material_schema.py`、`material_assets.py`、`material_store.py`。路由入口改为显式模块注册,
`test_route_manifest.py` 固定 68 个 method/path 对。兼容门面仅保留历史内部测试/调用点,不复制状态。
- 阶段 7(完成,`a63a615514`):复核后保留 `ui_prompt_workbench.js` 与
`ui_prompt_source_deck.js` 的单工厂闭包边界,避免拆出大量可变状态参数;`ui_model_sources.js`
仍是模型来源这一项功能的查询/编辑/持久化协调器;`material_inspector.js` 仍聚焦共享元数据与
节点参数检查。发现 `api/utils.py` 确有五类独立职责,已拆为 `path_utils.py`、
`media_routes.py`、`translation_routes.py`、`gallery_routes.py`、`folder_types.py`,原文件仅作兼容导出。
Python/JavaScript 相对导入图均无环,未发现 `EXTRACTED`/待提取占位或旧模型/配方/素材星号路由导入。
最终通过全部 29 个 `.mjs` 检查、89 个 Python `unittest`、全量 `api/*.py` 编译和路由清单检查。
本轮未连接运行中的 ComfyUI,因此不声称已完成真实拖放、停靠、主题、媒体释放和点击路径视觉验收。
-133
View File
@@ -1,133 +0,0 @@
# 工具箱与快捷栏自定义:交给 Gemini 的实施计划
状态:待实施的产品与工程计划,不代表当前版本已经具备以下功能。
基线:`77ae7ce88`;开工时以实际 Git 差异和现有代码为准,不回退后续工作。
## 目标与范围
把左下角做成用户可整理的快捷栏:工具箱的工具能拖到栏中,栏中的可移动工具能拖回工具箱,也能调整顺序。
同时完成短文字、详细提示的出现时间与位置调整。保留当前紧凑布局、工具箱外框尺寸和现有工具行为。
先阅读 `AGENTS.md`、`docs/architecture/frontend.md`、`docs/architecture/update-guide.md`,仅加载本次相关内容。
本任务有拖动状态与持久化,按下面阶段实施;不借机重写整个侧栏、换 UI 框架或引入拖拽依赖。
## 一、交互设计定稿要求
实施前用简短文字或草图说明:正常、拖出、拖回、排序、容量已满、取消六种状态。
按本计划做出完整方案后直接实施;常规视觉细节由 Gemini 决定,不增加多轮审批。若与现有代码冲突,优先保持业务语义。
### 1. 工具箱是完整目录,底栏是快捷入口
- 工具箱始终保留所有可用工具。拖到快捷栏代表“固定”,工具箱卡片仍在,并标注“已固定”。
- 从快捷栏拖回工具箱代表“从快捷栏移除”,不删除工具、不关闭正在执行的任务、不更改数据。
- 第一版固定两个锚点:最左的工具箱、最右的设置。它们不能被拖走,防止用户丢失整理和恢复入口。
- 中间最多 4 个可自定义入口,默认依旧是扫描、模型医生、节点助手、素材库,总共最多 6 个按钮。
- 工具箱中现有的导入导出中心、提示词工坊、翻译助手、模型来源也能固定到中间位置。
- 工具箱卡片顺序固定,快捷栏顺序由用户决定。第一版不做工具箱内部自由排序、分组、分页或自由浮动窗口。
- 拖出只接受快捷栏区域;丢到画布、图库、模型卡片或窗口外一律取消,不能生成节点或触发文件操作。
- 容量已满时不自动挤走其他工具,提示“快捷栏已满,请先将一个工具移回工具箱”;菜单固定与拖动规则一致。
- 重复固定不产生副本;已固定工具拖到快捷栏只调整已有位置。
- 提供“恢复默认快捷栏”,只重置本布局,不调用重置插件全部设置的功能。
### 2. 工具箱内的可发现性
- 在工具箱空白背景区域设置低干扰、可读的提示:“拖到下方,固定常用工具;拖回这里,收起快捷入口”。
- 使用独立的提示/放置区域,不把文字覆盖在卡片上;空间不足时改为底部短提示,不能撑大工具箱。
- 保持工具箱外框尺寸,工具增多只让内部列表滚动;底部提示/放置区保持可见,不遮挡卡片和关闭按钮。
- 工具卡片悬停或键盘聚焦时显露小拖动把手和“更多”操作入口;不要用常驻的大段教程挤占工具区域。
- 拖动快捷入口进入工具箱时,放置区域高亮并显示“松开,从快捷栏移除”;工具箱按钮本身也可作为拖回目标。
- 工具箱关闭时,拖到工具箱按钮上约 350 毫秒后自动展开;计时离开即取消。只在确实拖动快捷入口时启用。
- 没有完成放置时,不提前改动排列或保存设置。预览插入位置使用占位符/指示线。
### 3. 前一轮文字、时间与布局调整(本任务必做)
| 元素 | 第一版参数与行为 |
| --- | --- |
| 按钮内短文字 | 鼠标停留约 100 毫秒后,以约 120 毫秒的向上淡入替换图标;保持按钮尺寸与点击区域不变 |
| 完整说明框 | 从进入按钮开始累计约 600 毫秒后出现;不是短文字结束后再多等 600 毫秒 |
| 离开按钮 | 取消未触发的等待,详细提示立即隐藏;图标在约 100 毫秒内恢复,不保留离场延迟 |
| 说明框位置 | 默认在按钮上方,间隔约 8 像素;左右限制在实际侧栏边界内,并兼顾视口 |
| 排版 | 名称稍突出;正文常规字重、颜色稍淡、自然换行;不把整段解释都加粗 |
| 键盘 | focus-visible 立即显示短文字和清晰焦点,详细说明遵循约 600 毫秒等待;失焦立即清理 |
| 无悬停设备 | 短文字直接可见;提供点击“更多”的整理方式,不依赖悬停或长按才能使用 |
| 减少动态效果 | 保留识别文字,但取消位移动画,遵守已有 prefers-reduced-motion 规则 |
这些时间是初始设计值,真实浏览器验证时允许小幅调整,最终只在一处定义,不能散落在多个模块。
从一个按钮快速移到另一个时,重新计时,不闪现上一个按钮的说明。
拖动开始立即隐藏所有按钮提示,拖动期间不显示短文字过渡和说明框;结束后等待新的悬停/聚焦再触发。
提示框不能拦截点击、遮挡拖放目标;不要再使用超宽且正文加粗的提示框。
当前 tooltip 使用伪元素:若要分开标题/正文或准确限制边界,替换这一组按钮的提示渲染为一个共享提示节点,并移除旧提示属性/规则,避免出现两套提示。
不要改动插件所有其他 tooltip。新增可固定工具也必须有短标签、完整名称、说明与中英文翻译。
### 4. 拖动和普通点击必须分清
- 鼠标按下不立即拖动,移动超过约 6 CSS 像素才进入拖动态;工具箱优先从把手起拖,卡片正文点击继续打开工具。
- 快捷栏支持按住按钮拖动。超过阈值后的 pointerup/click 不能打开工具或触发扫描,即使最后取消也不能误点击。
- 执行原有动作只允许来自明确的普通点击或键盘激活;拖动预览不能调用工具 action。
- 拖动时保留来源占位、显示小型拖动预览和有效放置提示,不扩大按钮、推动整个侧栏或影响画布缩放。
- 拖动过程暂缓工具箱的“点击外部关闭”。与现有 document mousedown 关闭监听统一协调,不能边拖边把目标关闭。
- Esc、pointercancel、失焦、关闭插件、语言切换或重建相关视图均取消拖动并恢复原布局;清理指针捕获、全局监听、计时器和预览。
- 自动展开的工具箱取消后恢复此前开关状态;原本打开的工具箱保持打开。放置后可保留打开,方便继续整理。
- 在插件缩放和停靠模式下统一使用视口坐标与 getBoundingClientRect,不能混用缩放前尺寸计算落点。
### 5. 不会拖动也能完成整理
工具箱的“更多”操作提供“固定到快捷栏/从快捷栏移除”;快捷入口提供“左移/右移/移回工具箱”。
不要仅提供右键菜单,也不要依赖鼠标。使用真实 button、可读名称、可见焦点;操作后把焦点留在合理的位置。
菜单和拖动走同一个布局修改函数,包含相同的容量、去重、固定入口约束;成功放置可用 aria-live 简短播报。
## 二、工程结构
### 现有责任与风险
- `web/modules/ui_sidebar.js`:目前拥有工具箱卡片、侧栏按钮、扫描状态、打开/关闭和语言刷新;它已经很大,新完整拖动实现不要继续塞入 createDOM。
- `web/modules/sidebar_actions.js`:目前只认识六个硬编码 DOM ID;自定义后必须让新增快捷工具也能获取标签和用途。
- `web/modules/ui_lifecycle.js`:复用视图资源清理机制;不要新建永久全局拖动监听。
- `web/styles.css`:找到现有 sidebar-actions、toolbox、tooltip 规则并修改,移除本次取代的规则;禁止追加覆盖包。
- `web/modules/ui_update_guide.js`:保持上方手动入口,不因自定义布局或新引导版本恢复自动弹窗。
### 建议职责拆分(可按仓库命名习惯调整,不为拆分增加空壳)
1. 工具定义:一个稳定 ID 对应一份名称键、短标签键、说明键、图标和可固定属性;目录和快捷栏共用。动作回调在侧栏装配时显式传入,纯数据模块不读取画布。
2. 布局状态:单独的小模块负责默认值、去重/过滤、增删排序、容量与存储;只保存可移动 ID 的顺序,固定两端由视图添加。
3. 整理视图:独立模块负责拖动、放置预览、菜单与取消清理。只在有效放置/菜单确认时调用布局修改接口。
4. 现有 sidebar_actions 模块负责短标签和说明提示,可改为接收工具定义;禁止同时保留两套工具元信息和两套点击动作。
不要直接搬移或克隆工具 DOM 来实现固定:这会带走旧关闭逻辑、造成重复 ID 或丢失状态。
每个工具在两处调用同一个动作。保留现有隐藏其他面板、初始化、扫描中状态和错误反馈;不要通过隐藏按钮的 `.click()` 间接调用。
特别核对 `setScanButtonState`、`anomalous-scan-btn` 及语言刷新的所有调用方:扫描运行中移动入口,不能丢失进度、重复启动、产生失效引用。
DOM ID 保证唯一;两个可见入口用稳定 toolId 关联。当前未固定的工具也不能因为入口不在底栏而失去业务状态。
### 持久化与兼容
- 使用一个带 schemaVersion 的独立 localStorage 键,例如 `anomalous_shortcut_layout_v1`,内容仅为 `{ schemaVersion: 1, pinned: [...] }`。
- 首次使用采用原来的四个中间入口;显式的空数组是合法选择,不得又补回默认值。
- 读取时拒绝错误结构/未知版本;去掉未知工具 ID、重复项和固定锚点,限制容量;删除工具或升级后仍能打开界面。
- 不保存 DOM、翻译文本、回调代码或文件路径;不修改后端配置、工作流或素材数据。
- 写入失败保留当前会话布局,准确提示刷新后可能丢失;禁止伪报“已保存”,也不能让插件打不开。
- 第一版只在当前浏览器配置中持久化,不实现账户同步、多设备同步或布局导入导出。
## 三、不能改变的产品边界
- 导入导出快捷入口只打开现有已验证的 AMB0/AMB1 中心,仍是同一个中心;不能恢复配方、素材库、提示词工坊的独立文件导入导出。
- 保留素材提示词自动同步、来源标注和不可在工坊删除来源卡片的规则。
- 不修改图片/工作流哈希、模型识别、模型扫描内容、节点写入语义或用户文件。
- 不新增全局自动教程;功能上线后再更新引导内容配置和双语文案,仍由用户点击感叹号查看。
## 四、实施顺序与验收
1. **设计与状态**:说明六种交互状态,提取工具定义和布局纯逻辑,验证默认、空数组、去重、容量、损坏数据及存储失败。此阶段保留旧界面可运行。
2. **无拖动整理**:先让目录与快捷栏共用动作,通过菜单完成固定、移除、排序、恢复默认和刷新恢复;核对扫描状态、语言刷新、唯一 DOM ID。验证通过即本地提交。
3. **拖动与提示**:接入同一状态接口,实现阈值、放置、取消和文字/说明时间布局;验证后再提交,不另造第二套状态。
4. **交付**:仅更新改变的架构契约与用户可见更新说明。汇报实际验证、未测环境及本地提交,不推送 GitHub。
自动测试加载真实布局模块与交互模块,只模拟 DOM/存储边界。拖动至少覆盖成功放置、移出目标取消、Esc/父视图关闭清理、拖后不触发点击。
现有相关测试是 `node --experimental-vm-modules tests/update_guide.mjs`;扫描动作和已验证导入导出入口发生接线变化时追加相应回归,不默认跑全部后端测试。
新增测试确保被 Git 跟踪。语法/模块链接检查不能代替下面的浏览器操作:
- 工具箱 → 快捷栏、快捷栏 → 工具箱、栏内排序、重复固定、满栏拒绝、取消拖动;默认工具都能打开且只执行一次。
- 刷新、恢复默认、所有可移动项移除、扫描中移动入口、中英文切换;工具箱与设置始终可达。
- 快速掠过不闪说明,停留出现分层文案,边缘按钮提示不越出侧栏;拖动无提示遮挡。
- 普通窗口、窄侧栏、插件缩放、侧边停靠;工具箱尺寸稳定、内部可滚动、拖放坐标准确。
- 键盘菜单能完成同样操作;点击感叹号才显示更新引导。至少验证常用主题,未测环境如实说明。
按 AGENTS.md 及时保存阶段快照;若额度将尽或任务中断,保存有明确“验证待完成”说明的恢复快照,而不是无提交地把全部工作留到最后。
-88
View File
@@ -1,88 +0,0 @@
# 前端设计与 Gemini 协作方案
更新:2026-09-16。取代 9 月 9–11 日的前端视觉、布局建议,后续直接更新本文,不再按日期追加同类建议文件。
这是设计任务说明;工程规范以根目录 [AGENTS.md](../../AGENTS.md) 为准。具体任务若已有更新的专门方案,以专门方案为准。
## 1. 当前起点
本次核对基于 `3f71ddfcb` 的代码与计划,没有重新进行浏览器视觉验收。
- 配方已经有独立容器和工具栏,笔记有自己的标题,不再把“拆开创作工作台”列为待办。
- 提示词工坊已经独立。继续使用现有工坊,不恢复旧嵌入式组合面板,也不拿早期的简单组合设想限制现在已有的能力。
- 文件夹与配方覆盖层的定位问题已有修复,不重复下发修复任务。
- 工具箱已有入口;快捷栏自定义是**待实施计划**,见 [工具箱与快捷栏方案](customizable-tool-shortcuts.md)。不要把计划当作已经实现的功能。
- 当前配方包导入按钮处于禁用状态。设计可以解释不可用原因,不能为了演示效果直接启用、伪造成功或隐藏这个限制。
以上用于避免重复劳动,不代表所有关联功能已经验收通过。旧审查报告中的问题需要复现后判断,不能整份搬成当前待办,也不能直接宣布全部解决。
## 2. 怎样发挥 Gemini 的优势
让 Gemini 负责视觉层次、排版、密度、图标、过渡和响应式细节,并完成对应前端实现。不给每个像素下指令,也不要求每个小调整都等待批准。
任务边界和操作语义先明确:用户从哪里进入、想完成什么、写入什么、如何取消、完成后去哪里。Codex 可以负责梳理这些约束与检查数据、节点和生命周期;Gemini 做实现时也必须覆盖它们,不能把操作闭环全部留给下一位补。
**每轮交付一条完整操作流程。** “把工具箱做漂亮”范围模糊;“让用户从工具箱固定一个工具,再移除或取消,且任何时候都能恢复默认”才是可完成的任务。
## 3. 统一的设计方向
目标是清楚、紧凑、有质感的 ComfyUI 创作工具。图片和用户内容是视觉重点,装饰只辅助识别。
| 区域 | 设计职责 |
| --- | --- |
| 顶部导航 | 模型、图库、工作流配方;进入和返回的位置稳定 |
| 模型文件夹 | 只负责模型目录,不承担全局导航 |
| 左下工具区 | 默认保留素材库;工具箱是完整目录,快捷栏是常用入口。自定义行为遵循专门方案 |
| 列表工具栏 | 搜索、筛选、结果数、主操作;窄窗口可换行,控件不能互相挤没 |
| 内容卡片 | 图片、名称、类型和一项主操作。技术细节按需要展开,次操作可集中到“更多” |
| 详情与编辑 | 返回路径明确;区分查看、编辑、应用,明确应用到哪个节点或画布 |
不要为了统一外观强行让所有卡片显示同样字段。模型用于选择资产,配方用于复用工作流,素材用于复用内容,信息重点各不相同。
外观由 Gemini 在现有主题内自行发挥:统一按钮层级、间距、圆角与文字密度;选中、警告、禁用和键盘焦点要清楚。普通按钮不加持续发光或跳动。两套主题共享布局与操作规则,不能各自长出一套交互。
不把新的全站导航重构与本轮工具箱改动捆绑实施。发现其他页面需要调整时,写一句后续项即可。
## 4. 防止只考虑一个局部
动手前用几行列出本次操作链,并填下面这张表。只检查与本次变更有关的部分,不做每轮全仓审查。
| 必须想清楚的事 | 交付时需要回答 |
| --- | --- |
| 入口与出口 | 从哪个页面进入?关闭、返回、再次打开后分别是什么状态? |
| 正常与边界状态 | 加载、空内容、筛选无结果、失败、禁用、取消是否可理解?没有此状态的说明不适用 |
| 关联影响 | 改了共享按钮或浮层,哪些相邻页面、主题、提示框会受影响? |
| 操作对象 | 当前改的是快捷入口、素材数据、节点参数还是画布?文案是否说清楚? |
| 尺寸与输入 | 普通窗口、侧边停靠、长中文/英文、鼠标与键盘是否能完成同一任务? |
| 离开后的行为 | 悬浮预览、拖动、视频和请求会不会在关闭后继续活动或写错对象? |
例如“更多”菜单变漂亮,仍必须考虑靠右时是否出界、被父容器裁切、滚动时是否漂移,以及 Esc 关闭的是菜单还是整个浏览器。选择一个代表页面打磨公共样式后,再核对受影响页面,不要求一次重做它们。
## 5. 性能怎么兼顾
- 默认采用静态布局与局部短过渡。大面积模糊、连续光效、鼠标移动驱动全页动画必须有实际价值,否则不用。
- 大列表继续按现有分页、缩略图和懒加载策略工作;不能为视觉预览一次性加载全部原图、详情和视频。
- 工具提示随内容和交互更新,不为一小段文字增加持续轮询。隐藏页面的事件、预览和请求按照已有生命周期释放。
- 验证本次增加的成本:是否多了持续任务、重复请求或重复监听,滚动和拖动是否出现明显卡顿。需要声称性能提升时提供同一条件下的测量;没有测量就不报百分比,也不猜显存变化。
## 6. 推荐工作顺序
**第一轮:完成工具箱与快捷栏专门方案。** 这是明确的当前任务单;视觉与交互一起完成,包含正常放置、拖回、取消、重复固定、容量已满和恢复默认,不顺手改其他页面导航。
**第二轮:统一受影响的公共控件。** 以工具箱的按钮、提示、选中和禁用状态为起点,检查它们在模型、配方、素材库中的实际使用。修改原规则,清掉本次替代的旧声明。
**第三轮:逐页优化信息密度。** 先选一个真实使用频繁的页面,完整处理列表、详情、返回和失败反馈,再决定下一页。无需为了执行本文件同时美化全插件。
每轮完成后更新状态,删除已完成待办。验证发现的新问题直接修复;超出本轮范围的只记录具体触发条件,避免积累泛泛的“以后再优化”。
## 7. 可直接发给 Gemini 的任务模板
> 先阅读 GEMINI.md、AGENTS.md,以及 docs/plans/frontend-design-brief.md。本轮只完成【具体操作流程】,具体规则以【对应方案】为准。
>
> 先简述入口、正常操作、取消/失败、返回路径,以及会受影响的相邻界面,然后直接实施。颜色、排版和细节由你在现有主题内决定,不必逐项询问。
>
> 交付完整交互,不只做正常状态的截图。保留真实数据和操作语义,覆盖相关的窄窗口、主题、键盘与关闭路径。发现接口或业务阻塞时明确说明,不能用演示数据、假成功或静默隐藏功能替代。
>
> 完成后用简短清单说明:改变了什么、实际验证了哪些路径、哪些未验证、剩余的具体问题和本地提交。验证范围与改动相称,不默认跑全部测试;未完成项不得写成已完成。
这样用户主要判断“是否顺手、是否好看”,无需每次替实现者补齐所有边界情况。
-34
View File
@@ -1,34 +0,0 @@
# 后续构想:让 AI 从图库中帮助创作
记录日期:2026-09-18。
**状态:仅保存想法,现阶段不开发。本文只描述想实现什么,不规定代码、接口或技术方案。**
## 核心想法
图库不仅用于看图,也积累了生成图片时使用的提示词、模型、参数和工作流。未来允许用户连接自己选择的 AI 服务,从这些创作记录中总结经验,推荐值得尝试的工作流组合。
希望它能回答:“根据我喜欢的结果、已经拥有的模型和现在想画的内容,下一次可以怎样搭配?”
## 希望提供的体验
- **分析选中的作品**:选择一张或一组图片,查看它们使用了哪些模型、提示词和参数,有哪些共同点与差异。
- **推荐工作流组合**:结合创作目标与现有模型,推荐可尝试的节点、模型、提示词和参数组合,并解释推荐理由、参考了哪些作品、需要补充哪些条件。
- **总结个人偏好**:结合收藏、喜欢/不喜欢及主动评价,帮助用户发现自己偏好的风格和搭配,而不是把生成次数最多的组合当成最好。
- **比较与改进**:对比满意与不满意的结果,提出值得尝试的调整方向;通过后续试图和反馈,逐步判断建议是否适合用户。
- **整理可复用经验**:把用户认可的总结整理成提示词笔记、素材或工作流配方,方便再次使用。
- **探索更多玩法**:以后可以考虑用自然语言寻找历史作品、归纳某个模型的常用搭配、为新题材寻找可参考的旧工作流。逐项评估,不要求一次做完。
## 对结果的期望
推荐应当具体、可解释,区分“图库记录中的事实”和“AI 提出的尝试建议”。缺少必要模型或节点时提前说明,用户确认后再决定是否保存或应用。
仅凭参数不能保证找出“最佳工作流”,也不能把相关性当作某个参数造成画面改善的证明。有图片分析能力时可结合实际画面;没有时应明确仅依据参数与用户评价。
## 保留的边界
- 图库中并非每张图片都带有完整参数或工作流;缺失信息要明确标注,不让 AI 编造来源和配置。
- 用户主动选择分析范围,并能在发送前了解将交给外部 AI 的图片、提示词和工作流内容;不默认上传整个图库。
- 分析与应用分开:不自动改写现有工作流、不覆盖记录、不自动下载模型或启动生成。
- 用户能控制分析规模与费用;现有看图和素材功能保持独立,不连接 AI 也能正常使用。
这是一项长期探索方向,当前优先完成现有功能收尾。真正启动时再讨论最小可用范围。
-29
View File
@@ -1,29 +0,0 @@
# 素材库:保存画布选中节点
记录日期:2026-09-18。
**状态:后续需求,仅记录,本轮不开发。**
## 想做什么
在 ComfyUI 画布中选中一个或多个节点,通过一个方便的入口直接保存到素材库,方便以后复用,不必先生成图片或建立工作流配方。
- 单选:将这个节点及其当前参数保存为一份素材。
- 多选:将全部选中节点作为一个整体,保存为一份素材,不拆成多个独立条目。
- 保存选中节点之间的内部连线;与未选中节点相连的部分不自动扩展收录,不能留下指向不存在节点的无效连线。
- 保存不改变当前画布、节点参数或现有连接。
## 建议的最小交互
- 优先考虑节点或画布右键菜单中的“保存到素材库”,单选和多选共用入口;具体入口接入方式开发时核对 ComfyUI 能力。
- 轻量确认框显示“已选 N 个节点”,填写名称即可保存,标签可选;不强制提供封面。
- 未选中节点时不允许保存;成功提示只在实际保存完成后出现,并提供查看新素材的入口。失败保留输入,便于重试。
- 点击保存时固定节点选择与参数快照,后续切换选择不改变本次保存对象。
## 开发时注意
- 先核对现有节点素材、参数素材的保存与应用能力,复用素材库的存储、列表、详情和校验流程,不另建一套收藏系统。
- 当前图片/配方来源的保存接口不能直接假定支持画布快照;需要改协议时,明确新增来源的校验与兼容规则,不伪造图片来源。
- 单节点素材应能继续参与已有节点参数应用;多节点素材保留整体结构。首次开发需明确复用入口,保存成功后必须能在库中查看并通过相应流程使用。
- 先做好单选、多选、内部连线、外部连接边界、无选中节点和保存失败的验证;不借此扩展复杂节点组编辑器或自动收集依赖。
正式开发前遵循 [AGENTS.md](../../AGENTS.md),阅读 [素材库架构](../architecture/material-library.md),按最新实现细化方案。本文件不表示功能已完成。
-157
View File
@@ -1,157 +0,0 @@
# 模型来源中控:链接管理范围与后续方案
更新:2026-09-18。实施基线:`1b2013fe69`;接手时先核对最新代码。
**状态:2026-09-18 已完成“交给 5.6Sol”范围的代码与自动化验收;真实 ComfyUI 后端需重启后再做最终页面复核。** 本文是唯一交接计划,后续原地更新。
## 交给 5.6Sol:本轮只做目录范围对齐与基础组件折叠
### 目标与已确认原因
来源中控仍只管理链接。补齐本地模型列表,让用户通过现有文件夹管理决定纳入哪些目录,同时将基础组件默认收起,降低整理负担。
已确认的代码问题:
- 中控 `fetchAllLibraryModels()` 调用 `/anomalous/all_scan_models`,后端 `_collect_all_scan_models()` 只读分类模式的 `get_active_folder_types()`,没有遵循当前物理目录模式。
- 本次本地配置中,物理目录模式的 `clip` 已开启,但分类配置的 `text_encoders` 关闭。这能解释本地库遗漏 CLIP;不能因此认定所有缺项都只有这一种原因。
- 此接口只枚举 `.safetensors/.ckpt/.pt/.bin/.sft`,遗漏 `.gguf/.pth`,且扩展名比较区分大小写。
- 上轮提交只补了工作流收集、基础组件标签与对应类别的元数据读取,没有修复本地目录范围。
### 一、目录范围与文件格式
1. **本地库遵循文件夹管理当前模式。** 分类模式按分类勾选;物理目录模式按物理目录勾选。隐藏目录不遍历、不读取元数据;不自动开启 CLIP 等目录,不改用户配置。
2. 复用 `api/folder_types.py` 的模式、启用目录规则。若需提取目录枚举,放在该职责内,返回真实的 `type/path_idx/base_dir`;不能只拿物理目录名当模型类别。物理模式下同一类别可对应多个根目录,必须逐根判断,不能把启用其中一个解释为全部启用。
3. 对同一实际根目录进行规范化去重,保留可被后续接口解析的类别与路径索引。不要按 basename 合并模型;不同目录里的同名文件是独立条目。
4. 补齐此列表的 GGUF/PTH,并统一大小写判断。仅表示“可列出并记录链接”,不代表这些格式已支持元数据解析、身份恢复或云端识别。不要直接扩大全局 `MODEL_EXTENSIONS` 后让所有读写路径被动接受新格式。
5. `/all_scan_models` 同时被 `ui_model_selector.js` 使用。先检查两处调用;保持返回结构、分页与 `limit=0` 语义,验证选择器没有因此丢项或出现重复。确需区分调用用途时再加最小兼容参数,不先造专用数据库或另一套扫描系统。
6. **当前工作流仍列出实际引用的模型**,不被本地库隐藏目录设置抹掉。文件夹设置控制库盘点范围,不能让分享工作流时遗漏依赖。
### 二、基础组件默认折叠
- 工作流和本地库都保留两个显示组:主要模型直接显示;基础组件放进默认收起的 **“基础组件(高级) · N”**。使用可键盘操作的折叠控件,不再加一层弹窗。
- 本轮基础组件固定为现有策略已覆盖的 `clip/text_encoders/clip_vision/vae/vae_approx`。Checkpoint、扩散模型、LoRA、ControlNet 留在主要列表。其他已启用类别维持原有展示,避免顺手建设全站“核心/非核心”分类系统。
- 展开状态由当前中控会话持有,筛选、编辑、保存后的刷新应保留;重新打开默认收起。折叠时不要生成所有组件行的 DOM。
- 无来源的基础组件显示 **“未填写(可选)”**,不警告、不计入主要模型“来源待补充”数量;已有来源照常查看、修改和保存。
- “全部/已填写来源/来源待补充”筛选作用于主要模型;组件组独立于这三个来源状态筛选,展开后显示全部组件。搜索同时作用于两组,折叠栏数量随搜索更新;文案明确“基础组件来源可选,不计入待补”。
- 工作流里确定缺少本地文件的组件,折叠栏仍显示 **“缺少本地文件 × 项”**;该警告按当前范围全部组件计算,不被搜索或来源筛选隐藏。动态下拉选项未知不等于文件缺失,保留上轮判断保护。
- 底部统计明确标为“主要模型来源:已填写 X / Y”,使用当前搜索与筛选后的主要列表;组件数量单列在折叠栏,不把未填可选来源算成未完成任务。
- 无主要模型但存在组件时,保留组件入口;不能显示“未检测到任何模型”。目录未纳入范围时提供简短提示“本地库范围跟随设置 → 文件夹管理”,不增加重复配置界面。
- 所有新增文案同步中英文。使用现有样式变量和中性标签,不加发光、动效或强提醒。
### 三、保存、性能与职责边界
- 折叠只影响显示,不能删除已有来源或改变底层模型集合。保存到工作流、生成 Note 保留当前整份工作流语义;复制摘要应明确当前显示范围,不能因折叠悄悄改成导出所有内容。
- 本地保存继续使用准确的 `type/path_idx/subfolder/filename`。未定位实际文件的工作流组件只允许记录工作流链接,不得猜测路径写入 `.civitai.info`。
- 保留人工链接、原文件名、封面和已有 info。打开、展开、筛选均不触发云端扫描、哈希计算或附属文件写入。
- 列表枚举继续放在已有工作线程路径;折叠、搜索只处理已加载数据,不反复全盘读取。加载成功但为空、加载失败、尚未加载要能区分;打开时若指定 library 范围也须正确加载。
- `ui_model_sources.js` 已超过规模阈值:优先把目录结果整形、分组/筛选等有明确职责的逻辑提取到来源中控专用模块,弹窗保留生命周期与协调。只在本轮需要处提取,不做全仓拆分,也不把业务塞入通用 utils。
### 修改落点
| 位置 | 本轮责任 |
| --- | --- |
| `api/folder_types.py`、`api/model_catalog.py` | 当前模式对应的目录范围、路径去重、列表格式支持 |
| `web/modules/ui_model_sources.js` 及必要的来源专用模块 | 组件折叠、分组筛选、统计、空态与加载状态 |
| `web/modules/model_policies.js` | 复用现有类型判断,避免改动重命名或身份恢复策略 |
| `web/modules/locales.js`、`web/styles/10-model-sources.css` | 中英文文案、局部折叠布局 |
| `tests/model_sources_hub.mjs`、`tests/model_source_components.mjs` | 更新为新交互,同时保留准确定位与保存保护测试 |
| 对应 Python 测试 | 使用临时目录验证目录模式、过滤与去重,不能拿真实模型做写入夹具 |
### 验收清单
1. 物理模式开启 `clip`、分类配置关闭 `text_encoders` 时,库仍显示所选物理目录里的 CLIP;反向关闭物理目录时不枚举它。分类模式按自身配置工作。
2. 多根目录只启用其中一个、路径别名指向同一实际根、不同根有同名文件,均得到正确范围与去重结果;隐藏根目录零遍历、零元数据读取。
3. 无来源模型仍出现;GGUF/PTH 及大写扩展名可列出。现有格式、分页、选择器调用继续可用。
4. 基础组件初始折叠,展开后可查看/编辑;切换筛选或保存不丢展开状态与输入内容。主要待补计数不含组件;搜索、仅有组件、全空、加载失败均有明确结果。
5. 折叠时仍能看到组件缺文件告警;“无来源”和“文件缺失”独立。搜索无匹配不能抹掉当前工作流的缺文件告警。
6. 折叠与筛选不清空已保存链接。已定位文件准确保存;未知目标不写本地;关闭窗口后迟到请求不重新渲染。
7. 跑上述两组 JS 测试和对应 Python 目录枚举测试。实际 ComfyUI 验证两个范围、展开/收起、筛选搜索、窄窗口和原有选择器;写入/缺失场景用隔离夹具,不清空真实链接来制造测试条件。
8. Python 改动需重启 ComfyUI 才生效;只有前端刷新不能证明新接口已加载。不要强停有任务的实例;如暂不能验证新后端,明确报告缺口。
9. 完成后审查差异、更新本文状态与最窄的架构专题、本地提交,不推送。架构中的目录范围说明也要同步区分分类与物理模式。
### 本轮实施记录(2026-09-18)
- 已增加文件夹管理范围的统一根目录枚举,来源库按当前分类/物理模式取根,保留 `type/path_idx`、真实路径去重,并只在来源库列表补充大小写无关的 GGUF/PTH。
- 已提取来源中控专用的数据整形与分组模块;工作流和本地库的基础组件默认折叠,主要模型筛选、组件搜索计数、缺文件提示和当前可见范围复制各自保持独立语义。
- 自动化验证:来源中控两组 JS 专项测试、95 项 Python 测试、CSS/本地化契约通过;临时目录覆盖物理/分类范围、隐藏根零遍历与零元数据、路径别名、同名文件和 GGUF/PTH 大小写。
- 已在运行中的 ComfyUI 页面验证默认折叠、键盘展开、来源筛选不隐藏组件、搜索计数、主要模型统计与本地库范围提示;并在独立 Python 进程按真实物理配置确认 7 个启用根,其中 `text_encoders/path_idx=1` 正确对应已开启的 `clip` 根。运行实例未强停;Python 路由仍是重启前模块,目录范围新逻辑须在安全重启后复核。当前浏览器控制器不支持临时窄视口覆盖,窄窗口实机检查仍待补。
### 本轮不做
多来源切换、HF 抓图/info、平台多选、一键补全、扫描结果共享、全站格式支持和目录设置重做均不做。下文“链接可信度”中的历史识别/保存问题另列后续,不因本次计划一并扩张;若阻断上述验收,才做最小必要修复并说明。
## 已完成:工作流基础组件链接展示(2026-09-18)
- 复用模型类别判断,显示文本编码器、VAE、预览 VAE、视觉编码器标签;纳入无扩展名的组件选项及 PTH/GGUF 文件引用。
- 来源待补充使用普通提示,与本地缺失警告独立;保留手动填写和保存到工作流的能力。
- 基础组件只按对应类别、明确相对路径读取本地元数据,不搜索其他类别或进行云端扫描。不能定位实际文件时不提供本地附属文件写入;无扩展名选项、后端尚不支持定位的文件格式仍可保存工作流链接。
- 本地库目录范围、封面、文件名保持现状。大于 16 项的组件分批读取,覆盖后端请求上限。
- 验证:来源中控与组件专项测试通过;真实 ComfyUI 页面确认 CLIP/VAE 标签和已有链接正常。真实库当前没有待补来源条目,缺失、手动填写和保存分支使用隔离夹具验证,未改写用户链接或画布。
## 当前方向:先把链接管好
中控现阶段定位为模型链接管理页,负责查看、填写、修改、打开与保存来源链接。保留已有工作流/本地库范围与各自保存含义;不因为缩小范围顺手删除现有功能。
下次实施优先解决与链接可信度直接相关的问题:
- 无来源的模型必须能够被看到并补链接。“没有来源链接”不能成为排除模型的条件。
- 区分“未填写来源”和“本地缺少模型文件”,不把两者都显示成缺失模型。
- 自动识别失败返回的搜索页只能作为“去搜索”入口,不能保存为已识别来源、显示识别成功或计入已补全数量。当前 `autoDetectModelSource()` 的搜索回退及调用方提示需要一起检查。
- 保存成功以实际写入结果为准;明确“保存到本地”和“写入当前工作流”的区别。无法确定本地文件身份时,不凭同名文件猜测写入位置。
- 保留手工录入的合法网页链接,不把用户填写的地址称为“已验证官方来源”。打开链接不隐式抓图、覆盖介绍或启动扫描。
用户报告“缺失来源的模型没有显示”,当前静态代码没有直接按空链接排除列表项。本次已确认目录模式不一致与格式遗漏,见上面的实施范围;其他来源识别与保存问题仍需分别复现,不将它们统称为已修复。
## 暂缓一:模型详情的多来源入口
双链接可行,但应理解为“同一模型有多个来源”,不是切换到另一份模型数据。以下是后续建议,尚未实现。
- 只有一个来源时保持现有入口;两个来源时,详情的来源区域并列显示 `Civitai`、`Hugging Face` 两个文字按钮。无需为了两个链接引入整个页面的切换标签。
- 如果确实需要默认入口,可提供“首选来源”单选;切换只影响默认打开哪个链接,不改变封面、介绍、模型文件、哈希或扫描设置。
- 第三个及更多来源再考虑菜单,不预先建设无限多平台管理器。无链接的平台不显示空按钮。
- 当前 `civitai_url` 是独立字段,`source_url` 是通用自定义来源,不能假定后者必然是 HF。初版可优先复用现有字段;确需额外首选来源字段时,单独定义兼容规则。
- 多个链接并存不证明文件一致。确认匹配仍依赖现有模型身份规则;不能根据名称近似自动合并模型。
不建议首版让“切换来源”同时切换整套封面和 info:这会引入两套详情状态、缓存和失败回退,超出当前链接管理范围。
## 暂缓二:扫描来源的单选/多选
放在现有扫描向导或相关设置里,不在中控再做另一套扫描控制台。
明确区分三件事:
| 选项 | 含义 |
| --- | --- |
| 模型范围 | 扫描哪些本地模型,沿用现有选择能力 |
| 来源平台 | 去哪些已支持的平台获取信息,可单选或多选 |
| 首选链接 | 默认打开哪个已保存来源,与扫描平台互不联动 |
推荐平台选择采用复选框:只勾一个就是单来源,勾多个就是多来源,无需再增加“单选模式/多选模式”开关。只展示真正实现并可用的提供方,不能提前让 HF 扫描成为可点击的空功能。
多平台补全规则另行实施时必须一起确定:优先复用本地信息、只补缺项、保留人工内容、一个平台失败不丢其他结果、明确取消语义。已有链接不等于已有封面;已有 info 文件也不等于每个字段完整。
## 暂缓三:中控与扫描共享结果
两者共用现有模型附属信息读写能力,不建立中控专用数据库,也不让两个界面分别维护一份权威副本。
- 向导保存结果后,中控在打开时或收到完成通知后重新读取;不用持续轮询所有模型。
- 中控保存的手工链接,向导可读取并保护。链接只说明用户记录的来源,不代表扫描器已经支持解析该平台。
- 如增加“补全缺失来源”,仅复用已有扫描任务,限定当前范围并显示真实成功/未找到/失败结果,不自动扫描整个模型库。
## 暂缓四:HF 信息补全
- 工作流基础组件展示已完成,见上文;不据此扩大本地库目录范围或重做全站模型类型系统。
- 基础组件保留原文件名,不强制提供封面;没有封面时使用类型图标。
- 填写 HF 链接时保留现有封面、介绍和 Civitai 信息,不将自定义链接写进 Civitai 身份字段。
- 将来如实现 HF 信息读取,先只读明确仓库的模型卡与有明确声明的缩略图;缺失时保留本地信息。不做自动抓任意 README 图片、全网找封面或无限平台适配。
- 图片和介绍的来源与下载链接可以不同,但应可辨别,且不能把仓库级说明误当成已验证的具体文件信息。
参考:[HF 模型卡官方说明](https://huggingface.co/docs/hub/model-cards)。这只是后续技术参考,不表示本项目已支持自动抓取。
## 后续实施顺序
1. 先完成本文“交给 5.6Sol”范围:目录模式对齐、列表格式补齐、基础组件折叠与统计。
2. 再单独核查历史识别结果与保存反馈,完成链接管理的基本用途。
3. 确有需要时再做模型详情双来源入口;此时仍不联动封面或扫描。
4. 最后评估扫描结果共享、平台多选及 HF 元数据读取,逐项实施,不一次打包上线。
实施时遵循 [AGENTS.md](../../AGENTS.md),核对最新代码和相关测试;本文不替代架构契约,也不授权自动启用所有暂缓功能。
+106 -1
View File
@@ -1,7 +1,65 @@
"""File SHA-256 evidence shared by the standalone scanner and API."""
"""File SHA-256 evidence, the base model a safetensors header tells, and the model
sidecars, shared by the standalone scanner and API.
A model's information comes in three layers, the user's first:
- <model>.anomalous.json what the user set in the model editor (only the editor writes it);
- <model>.info what a scan found: Civitai's record, or (id -1) what the file
itself tells; <model>.civitai.info is the same from other tools;
- the model file itself.
"""
import hashlib
import json
import os
import re
import struct
USER_INFO_SUFFIX = ".anomalous.json"
def read_json(path):
"""A JSON object from ``path``; None when missing, unreadable or not an object."""
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def scan_info_path(base):
"""The sidecar holding a scan result: <model>.info, else another tool's <model>.civitai.info.
A .civitai.info with only edits made in older versions (no Civitai ids) does not count,
so editing a model's name never makes it look scanned. ``base``: the path without extension."""
if os.path.exists(base + ".info"):
return base + ".info"
data = read_json(base + ".civitai.info")
if data is not None and ("id" in data or "modelId" in data):
return base + ".civitai.info"
return None
def sidecar_info(base):
"""The scan result (see scan_info_path) as a dict; None when there is none or it is unreadable."""
path = scan_info_path(base)
return read_json(path) if path else None
def is_unmatched(info):
"""Info the scanner wrote from the file alone: Civitai had no match, or was not reachable then."""
return isinstance(info, dict) and info.get("id") == -1
# 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")
def unmatched_reason(info):
"""One of UNMATCHED_REASONS for an unmatched model's info, else ""."""
reason = info.get("anomalous_unmatched_reason") if is_unmatched(info) else ""
return reason if reason in UNMATCHED_REASONS else ""
def normalise_sha256(value):
@@ -37,3 +95,50 @@ def sidecar_file_hash(data, path, selected_file):
return "", ""
hashes = selected_file.get("hashes", {}) if isinstance(selected_file, dict) else {}
return normalise_sha256(hashes.get("SHA256")), "sidecar file SHA-256"
def file_sha256(path):
"""The complete file's SHA-256, hex."""
digest = hashlib.sha256()
with open(path, "rb") as handle:
for block in iter(lambda: handle.read(4096 * 1024), b""):
digest.update(block)
return digest.hexdigest()
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'
+1 -1
View File
@@ -13,7 +13,7 @@ PHYSICAL_RENAME_PROTECTED_TYPES = frozenset({
})
# This is a recovery-confidence policy, not a scanner-support list. The scan
# wizard may hash these folders, but Model Doctor must not redirect one of these
# wizard may hash these folders, but Model Check must not redirect one of these
# components from size, filename, metadata labels, or Civitai availability.
HASH_ONLY_RECOVERY_TYPES = frozenset({
"vae",
+77
View File
@@ -0,0 +1,77 @@
"""Deleting the user's files: always to the system Recycle Bin, never permanently.
Shared by the API and the standalone scanner. Where no recycle bin exists (a USB stick,
a network share, a Linux box without send2trash or gio), nothing is deleted and
TrashUnavailable says so; the caller reports it instead of falling back to a permanent
delete. Temporary files the plugin made itself are not deleted through here.
"""
import os
import shutil
import subprocess
import sys
class TrashUnavailable(OSError):
"""There is no recycle bin for this location; the files were left in place."""
def _windows_fixed_drive(path):
import ctypes
root = os.path.splitdrive(path)[0]
if not root or root.startswith("\\\\"):
return False # a UNC share: no recycle bin
return ctypes.windll.kernel32.GetDriveTypeW(root + "\\") == 3 # DRIVE_FIXED
def _windows_recycle(paths):
import ctypes
from ctypes import wintypes
class SHFILEOPSTRUCTW(ctypes.Structure):
_fields_ = [
("hwnd", wintypes.HWND), ("wFunc", wintypes.UINT),
("pFrom", wintypes.LPCWSTR), ("pTo", wintypes.LPCWSTR),
("fFlags", ctypes.c_ushort), ("fAnyOperationsAborted", wintypes.BOOL),
("hNameMappings", ctypes.c_void_p), ("lpszProgressTitle", wintypes.LPCWSTR),
]
if not all(_windows_fixed_drive(path) for path in paths):
raise TrashUnavailable("No Recycle Bin on this drive (removable or network)")
# FO_DELETE with FOF_ALLOWUNDO recycles; no dialogs. FOF_WANTNUKEWARNING makes Windows
# ask instead of silently deleting if this drive's Recycle Bin is switched off.
flags = 0x0040 | 0x0010 | 0x0004 | 0x0400 | 0x4000
op = SHFILEOPSTRUCTW(None, 3, "\0".join(paths) + "\0", None, flags, False, None, None)
result = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
if result != 0 or op.fAnyOperationsAborted:
raise OSError(result, f"Could not move to the Recycle Bin (code {result}); the file may be in use")
def _posix_recycle(paths):
try:
from send2trash import send2trash
except ImportError:
send2trash = None
if send2trash:
for path in paths:
send2trash(path)
return
gio = shutil.which("gio")
if not gio:
raise TrashUnavailable("No trash available (install send2trash)")
for path in paths:
subprocess.run([gio, "trash", path], check=True, capture_output=True)
def move_to_trash(*paths):
"""Moves the existing files and folders among ``paths`` to the Recycle Bin, together.
Returns the paths moved. Raises TrashUnavailable (nothing moved) or OSError."""
targets = [os.path.abspath(path) for path in paths if path and os.path.lexists(path)]
if not targets:
return []
if sys.platform == "win32":
_windows_recycle(targets)
else:
_posix_recycle(targets)
return targets
+345 -437
View File
@@ -1,9 +1,9 @@
import os
import sys
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPT_DIR not in sys.path:
sys.path.insert(0, _SCRIPT_DIR)
try:
import os
import sys
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPT_DIR not in sys.path:
sys.path.insert(0, _SCRIPT_DIR)
try:
sys.stdout.reconfigure(encoding="utf-8")
except:
pass
@@ -13,134 +13,83 @@ except:
pass
import time
import json
import hashlib
import re
import urllib.request
import urllib.error
import urllib.parse
import argparse
import shutil
from typing import Dict, Optional
from model_policies import is_physical_rename_protected
from model_identity import computed_file_identity
# Fixed tuples avoid rebuilding long extension lists for every scanned model.
# Exact-path checks are intentionally used instead of directory-wide globbing.
MEDIA_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi")
PREVIEW_SUFFIXES = tuple(f".preview{ext}" for ext in MEDIA_EXTENSIONS)
CIVITAI_BACKUP_SUFFIXES = tuple(f".civitai_bak{ext}" for ext in MEDIA_EXTENSIONS)
COVER_SUFFIXES = MEDIA_EXTENSIONS + PREVIEW_SUFFIXES
ACTIVE_COVER_SUFFIXES = PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
SIDECAR_SUFFIXES = (
".info", ".civitai.info", ".json", ".txt", ".yaml",
*MEDIA_EXTENSIONS,
*PREVIEW_SUFFIXES,
*CIVITAI_BACKUP_SUFFIXES,
)
def write_scan_progress(progress_file, phase, total=0, current=0, filename=""):
if not progress_file:
return
temp_file = f"{progress_file}.{os.getpid()}.tmp"
try:
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump({
"phase": phase,
"total": total,
"current": current,
"filename": filename,
}, f, ensure_ascii=False)
os.replace(temp_file, progress_file)
except OSError:
try:
os.remove(temp_file)
except OSError:
pass
def count_scan_files(target_folder, target_files_basenames):
total = 0
selected = set(target_files_basenames)
for _, _, files in os.walk(target_folder):
total += sum(
1 for filename in files
if filename.endswith(".safetensors") and (not selected or filename in selected)
)
return total
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 civitai_client import CIVITAI_API_KEY, CivitaiUnreachable, download_media, fetch_civitai_info, fetch_model_description
# ==============================================================================
# CIVITAI API 配置读取
# 请在插件目录 (Anomalous_Model_Browser) 下新建 config.json 文件:
# { "CIVITAI_API_KEY": "你的KEY" }
# ==============================================================================
CIVITAI_API_KEY = None
plugin_dir = os.path.dirname(os.path.abspath(__file__))
config_paths = [os.path.join(plugin_dir, "api", "config.json"), os.path.join(plugin_dir, "config.json")]
for config_path in config_paths:
if not os.path.exists(config_path):
continue
try:
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
configured_key = cfg.get("CIVITAI_API_KEY", "")
if isinstance(configured_key, str) and configured_key.strip():
CIVITAI_API_KEY = configured_key.strip()
break
except Exception as e:
print(f"[-] 读取 config.json 失败: {e}")
if not CIVITAI_API_KEY:
print("[!] 未配置 Civitai API Key。部分限制级模型或将无法获取图片。")
# Fixed tuples avoid rebuilding long extension lists for every scanned model.
# Exact-path checks are intentionally used instead of directory-wide globbing.
MEDIA_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".mp4", ".webm", ".mov", ".avi")
PREVIEW_SUFFIXES = tuple(f".preview{ext}" for ext in MEDIA_EXTENSIONS)
CIVITAI_BACKUP_SUFFIXES = tuple(f".civitai_bak{ext}" for ext in MEDIA_EXTENSIONS)
COVER_SUFFIXES = MEDIA_EXTENSIONS + PREVIEW_SUFFIXES
ACTIVE_COVER_SUFFIXES = PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
SIDECAR_SUFFIXES = (
".info", ".civitai.info", USER_INFO_SUFFIX, ".json", ".txt", ".yaml",
*MEDIA_EXTENSIONS,
*PREVIEW_SUFFIXES,
*CIVITAI_BACKUP_SUFFIXES,
)
def same_file(a, b):
"""Byte-for-byte equal (covers are small; size is checked first)."""
try:
return os.path.getsize(a) == os.path.getsize(b) and calculate_sha256(a) == calculate_sha256(b)
except OSError:
return False
def cover_owners(base):
"""(Civitai's, the user's) active covers. A cover is Civitai's only when it is a copy of the
Civitai image saved last time (<model>.civitai_bak.*); any other cover is the user's."""
backups = [base + ext for ext in CIVITAI_BACKUP_SUFFIXES if os.path.exists(base + ext)]
covers = [base + ext for ext in ACTIVE_COVER_SUFFIXES if os.path.exists(base + ext)]
civitai = [c for c in covers if any(same_file(c, b) for b in backups)]
return civitai, [c for c in covers if c not in civitai]
def write_scan_progress(progress_file, phase, total=0, current=0, filename=""):
if not progress_file:
return
temp_file = f"{progress_file}.{os.getpid()}.tmp"
try:
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump({
"phase": phase,
"total": total,
"current": current,
"filename": filename,
}, f, ensure_ascii=False)
os.replace(temp_file, progress_file)
except OSError:
try:
os.remove(temp_file)
except OSError:
pass
def count_scan_files(target_folder, target_files_basenames, unmatched_only=False):
total = 0
selected = set(target_files_basenames)
for root, _, files in os.walk(target_folder):
total += sum(
1 for filename in files
if filename.endswith(".safetensors") and (not selected or filename in selected)
and (not unmatched_only or is_unmatched(read_local_info(os.path.join(root, os.path.splitext(filename)[0]))))
)
return total
def calculate_sha256(file_path: str) -> str:
"""计算文件的 SHA256 哈希值 (用于 Civitai 匹配)"""
sha256_hash = hashlib.sha256()
print(f"[*] 正在计算 Hash (大文件可能需要几分钟): {os.path.basename(file_path)}")
with open(file_path, "rb") as f:
for byte_block in iter(lambda: f.read(4096 * 1024), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
import struct
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'
return file_sha256(file_path)
def sanitize_filename(name: str) -> str:
"""清理文件名中的非法字符"""
@@ -148,71 +97,212 @@ def sanitize_filename(name: str) -> str:
name = re.sub(r'[\\/*?:"<>|#]', "", name)
return name.strip(' .')
def fetch_civitai_info(file_hash: str, max_retries: int = 3) -> Optional[Dict]:
"""向 Civitai API 获取模型信息,支持重试机制"""
url = f"https://civitai.com/api/v1/model-versions/by-hash/{file_hash}"
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"
}
if CIVITAI_API_KEY:
headers["Authorization"] = f"Bearer {CIVITAI_API_KEY}"
for attempt in range(max_retries):
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, 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})")
if attempt < max_retries - 1:
time.sleep(2)
CIVITAI_DOWN_AFTER = 2 # models in a row Civitai did not answer for before the rest of the run stays offline
print(f"\033[93m[Skip] 模型 Hash {file_hash} 网络重试失败,已跳过该文件。\033[0m")
return None
def download_media(url: str, base_path: str, max_retries: int = 3):
"""下载图片或视频并自动识别扩展名"""
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"
def write_report(args, **entry):
"""One line of the scan report (--report-file): a model's outcome, or an event of the run."""
if not args.report_file:
return
try:
with open(args.report_file, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except OSError as e:
print(f"[-] 扫描记录写入失败: {e}")
def local_record(file_path, root, file_hash, reason):
"""What the file alone tells (id -1), with why Civitai's record is missing."""
inferred_base = infer_base_model_from_header(file_path)
stem = os.path.splitext(os.path.basename(file_path))[0]
print(f"[*] 使用本地哈希重建基础元数据 ({os.path.basename(file_path)})")
return {
"id": -1,
"modelId": -1,
"name": stem,
"baseModel": "" if inferred_base == 'Unknown' else inferred_base,
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
"model": {"name": stem, "type": "LORA" if "lora" in root.lower() else "Checkpoint"},
"files": [{"hashes": {"SHA256": file_hash}}],
"anomalous_unmatched_reason": reason,
}
if CIVITAI_API_KEY:
headers["Authorization"] = f"Bearer {CIVITAI_API_KEY}"
for attempt in range(max_retries):
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=15) as response:
content_type = response.headers.get("Content-Type", "").lower()
ext = ".png" # default
if "video/mp4" in content_type: ext = ".mp4"
elif "video/webm" in content_type: ext = ".webm"
elif "image/jpeg" in content_type: ext = ".jpg"
elif "image/webp" in content_type: ext = ".webp"
elif url.endswith(".mp4"): ext = ".mp4"
final_path = base_path + ext
with open(final_path, 'wb') as f:
while True:
chunk = response.read(8192)
if not chunk:
break
f.write(chunk)
return final_path
except urllib.error.HTTPError as e:
print(f"[-] 媒体下载失败,状态码: {e.code} (尝试 {attempt+1}/{max_retries})")
except urllib.error.URLError as e:
print(f"[-] 媒体下载网络异常: {e.reason} (尝试 {attempt+1}/{max_retries})")
except Exception as e:
pass
return None
def ask_civitai(args, state, file_hash):
"""(Civitai's record or None, why it is missing: "not_found" | "network" | "offline" | "")."""
if args.offline_only:
return None, "offline"
if state["civitai_down"]:
return None, "network"
try:
data = fetch_civitai_info(file_hash)
except CivitaiUnreachable:
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")
return None, "network"
state["network_failures"] = 0
return data, ("" if data else "not_found")
def scan_one(args, state, rename_log, root, filename, previous):
"""Scans one model; returns its report entry, or None when nothing about it changed."""
file_path = os.path.join(root, filename)
old_base = os.path.splitext(file_path)[0]
online = not args.offline_only and not state["civitai_down"]
# A .civitai.info holding only edits from older versions is not a scan result.
info_exists = scan_info_path(old_base) is not None
# Models Civitai was never asked about (an offline scan, no connection then) are looked up
# by every online scan; models Civitai did not know wait for "look up again".
unasked = is_unmatched(previous) and unmatched_reason(previous) in ("offline", "network")
if args.force_overwrite or args.retry_unmatched or (online and unasked):
info_exists = False
preview_exists = args.skip_media or any(os.path.exists(old_base + ext) for ext in COVER_SUFFIXES)
needs_rename = bool(args.virtual_rename or (not args.skip_rename and args.physical_rename))
if info_exists and preview_exists and not needs_rename:
print(f"[*] 已跳过 (信息满足要求): {filename}")
return None
print(f"\n---> 处理文件: {filename} (位于 {root})")
civitai_data = None
file_hash = None
reason = ""
if info_exists: # a rename, or a missing cover fetched from the saved record: no new lookup
civitai_data = read_local_info(old_base)
if civitai_data:
print(f"[*] 使用本地已有的信息")
if not civitai_data:
if is_unmatched(previous): # the hash from last time, if the file is unchanged
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)
# What was read from disk is not fetched again: only fresh Civitai data needs the model
# page's description and the cover.
fresh = civitai_data is not None and file_hash is not None
if not civitai_data:
if args.skip_local_metadata:
print(f"[*] Civitai 获取失败,且禁用了本地元数据解析。跳过 {filename}")
return {"status": "failed", "error": "no_local_metadata", "reason": reason}
civitai_data = local_record(file_path, root, file_hash, reason)
entry = {"status": "matched" if fresh else "inferred" if reason else "unchanged"}
if reason:
entry["reason"] = reason
model_id = civitai_data.get("modelId")
if fresh and model_id and model_id != -1 and not state["civitai_down"]:
description = fetch_model_description(model_id)
if description:
civitai_data["description"] = description
if not isinstance(civitai_data.get("model"), dict):
civitai_data["model"] = {}
civitai_data["model"]["description"] = description
model_name = sanitize_filename(civitai_data.get("model", {}).get("name", "UnknownModel"))
version_name = sanitize_filename(civitai_data.get("name", "UnknownVersion"))
if fresh:
entry["name"] = f"{civitai_data.get('model', {}).get('name', '')} · {civitai_data.get('name', '')}".strip(" ·")
entry["base"] = civitai_data.get("baseModel") or ""
new_filename = f"{model_name}_{version_name}.safetensors"
new_file_path = os.path.join(root, new_filename)
new_base = os.path.splitext(new_file_path)[0]
info_data = civitai_data
if file_hash:
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
# What the file alone tells has no standard name: both names are the file's own, so a
# rename would only repeat it (name_name, then name_name_name_name on the next scan).
inferred = is_unmatched(civitai_data)
# Civitai's name for the card; a name set in the model editor lives in
# <model>.anomalous.json and is shown instead.
if inferred:
info_data.pop("anomalous_custom_name", None)
elif args.virtual_rename:
info_data["anomalous_custom_name"] = f"{model_name}_{version_name}"
if not args.dry_run:
with open(old_base + ".info", 'w', encoding='utf-8') as f:
json.dump(info_data, f, ensure_ascii=True, indent=4)
print(f"[+] 写入纯净版 Civitai 描述 -> .info")
else:
print(f"[Dry-Run] 拟生成标准描述信息 -> .info")
media_url = None
if not args.skip_media:
media_url = next((img.get("url") for img in civitai_data.get("images") or [] if img.get("url")), None)
civitai_covers, user_covers = cover_owners(old_base)
# 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 not args.dry_run:
print(f"[*] 正在下载预览媒体...")
saved_path = download_media(media_url, old_base + ".civitai_bak")
if saved_path:
print(f"[+] 媒体下载成功 -> {os.path.basename(saved_path)}")
entry["cover"] = "kept" if user_covers else "new"
if user_covers:
# The user's cover always stays; Civitai's image is kept as the backup.
print(f"[*] 保留你自己的封面,Civitai 封面存为备份")
else:
ext = os.path.splitext(saved_path)[1]
preview_path = old_base + (ext if ext.startswith('.preview.') else f".preview{ext}")
# Earlier copies of Civitai's image under another name give way to the new one.
older = [c for c in civitai_covers if c != preview_path]
try:
if older:
move_to_trash(*older)
except Exception as e:
print(f"[-] 旧的 Civitai 封面没有移走: {e}")
shutil.copy2(saved_path, preview_path)
else:
print(f"[Dry-Run] 拟下载预览媒体...")
if args.skip_rename or not args.physical_rename:
print(f"[*] 物理重命名已跳过。仅保存 .info 及其可能包含的虚拟重命名。")
elif inferred:
print(f"[*] 没有 Civitai 的标准名称,保留原文件名。")
elif file_path != new_file_path and new_filename != filename:
if os.path.exists(new_file_path):
print(f"[*] 目标文件名已存在,正在验证内容是否完全相同: {filename}")
try:
files_identical = os.path.getsize(file_path) == os.path.getsize(new_file_path)
if files_identical:
files_identical = calculate_sha256(file_path) == calculate_sha256(new_file_path)
except OSError:
files_identical = False
if not files_identical:
print(f"\033[91m[-] 目标名称冲突但文件内容不同,已保留两个模型并跳过重命名: {filename}\033[0m")
return {**entry, "status": "failed", "error": "name_conflict", "renamed_to": new_file_path}
if not args.dry_run:
try:
print(f"[*] Hash 一致,把已确认的重复副本移到回收站: {filename}")
move_to_trash(file_path, *[old_base + ext for ext in SIDECAR_SUFFIXES])
entry["duplicate_of"] = new_file_path
except Exception as e:
print(f"[-] 重复副本没有移走,两份都保留 (可能文件被占用或这个盘没有回收站): {e}")
return {**entry, "status": "failed", "error": "duplicate_kept"}
else:
print(f"[Dry-Run] Hash 一致,拟删除重复副本及其附属文件: {filename}")
elif not args.dry_run:
os.rename(file_path, new_file_path)
rename_log[file_path] = new_file_path
for ext in SIDECAR_SUFFIXES:
if os.path.exists(old_base + ext):
os.replace(old_base + ext, new_base + ext)
print(f"[+] 物理重命名完成: {filename} ==> {new_filename}")
entry["renamed_to"] = new_file_path
else:
print(f"[Dry-Run] 拟物理重命名文件: {filename} ==> {new_filename}")
print(f"[Dry-Run] 拟连带重命名附属文件 (.info / .png 等)")
else:
print("[*] 文件名已符合规范,无需重命名。")
changed = entry["status"] != "unchanged" or set(entry) & {"cover", "renamed_to", "duplicate_of"}
return entry if changed else None
def main():
parser = argparse.ArgumentParser(description="ComfyUI 模型 Civitai 嗅探与重命名工具")
@@ -225,20 +315,27 @@ def main():
parser.add_argument("--skip-media", action="store_true", help="不下载预览图或视频")
parser.add_argument("--offline-only", action="store_true", help="跳过 Civitai 联网获取,强制使用本地脱机张量推断提取 Base Model")
parser.add_argument("--force-overwrite", action="store_true", help="强制覆盖已存在的信息文件")
parser.add_argument("--retry-unmatched", action="store_true", help="只处理之前没在 Civitai 匹配到的模型,重新联网查找(文件没变时复用哈希,不替换封面)")
parser.add_argument("--skip-local-metadata", action="store_true", help="忽略本地已有的.info / .json文件")
parser.add_argument("--target-files", type=str, default="", help="仅扫描逗号分隔的具体文件(相对路径)")
parser.add_argument("--folder-type", default="", help="由 ComfyUI 传入的模型目录类型,用于执行安全策略")
parser.add_argument("--progress-file", default="", help=argparse.SUPPRESS)
args = parser.parse_args()
parser.add_argument("--target-files", type=str, default="", help="仅扫描逗号分隔的具体文件(相对路径)")
parser.add_argument("--folder-type", default="", help="由 ComfyUI 传入的模型目录类型,用于执行安全策略")
parser.add_argument("--progress-file", default="", help=argparse.SUPPRESS)
parser.add_argument("--targets-file", default="", help=argparse.SUPPRESS) # JSON list of file names
parser.add_argument("--report-file", default="", help=argparse.SUPPRESS) # one JSON line per changed model
parser.add_argument("--civitai-down", action="store_true", help=argparse.SUPPRESS) # an earlier folder could not reach it
args = parser.parse_args()
if args.offline_only:
# Offline means no network at all: previews and model pages come from Civitai.
args.skip_media = True
target_folder = args.folder
if not os.path.isdir(target_folder):
print(f"[-] 错误: 文件夹不存在 -> {target_folder}")
sys.exit(1)
if args.physical_rename and is_physical_rename_protected(args.folder_type, target_folder):
print("[*] 基础组件目录受保护:VAE、CLIP/Text Encoder 与 CLIP Vision 不执行物理重命名。")
args.physical_rename = False
if not os.path.isdir(target_folder):
print(f"[-] 错误: 文件夹不存在 -> {target_folder}")
sys.exit(1)
if args.physical_rename and is_physical_rename_protected(args.folder_type, target_folder):
print("[*] 基础组件目录受保护:VAE、CLIP/Text Encoder 与 CLIP Vision 不执行物理重命名。")
args.physical_rename = False
backup_log_path = os.path.join(target_folder, "backup_rename_log.json")
@@ -265,7 +362,7 @@ def main():
old_base = os.path.splitext(old_path)[0]
new_base = os.path.splitext(new_path)[0]
for ext in SIDECAR_SUFFIXES:
for ext in SIDECAR_SUFFIXES:
new_ext_path = new_base + ext
old_ext_path = old_base + ext
if os.path.exists(new_ext_path):
@@ -280,271 +377,82 @@ def main():
# 模式二:正常嗅探与重命名
# ==========================
rename_log = {}
success_count = 0
fail_count = 0
if os.path.exists(backup_log_path):
with open(backup_log_path, 'r', encoding='utf-8') as f:
rename_log = json.load(f)
target_files_basenames = []
target_file_path = os.path.join(target_folder, '.scan_targets.json')
if os.path.exists(target_file_path):
for targets_path in (os.path.join(target_folder, '.scan_targets.json'), args.targets_file):
if not targets_path or not os.path.exists(targets_path):
continue
try:
with open(target_file_path, 'r', encoding='utf-8') as f:
target_files_basenames = [os.path.basename(t.strip()) for t in __import__('json').load(f)]
os.remove(target_file_path)
except:
with open(targets_path, 'r', encoding='utf-8') as f:
target_files_basenames += [os.path.basename(t.strip()) for t in json.load(f)]
if targets_path != args.targets_file: # the scanner removes its own file
os.remove(targets_path)
except (OSError, ValueError, AttributeError):
pass
if args.target_files:
target_files_basenames.extend([os.path.basename(t.strip()) for t in args.target_files.split(',')])
print(f"[*] 开始扫描文件夹: {target_folder}")
print(f"[*] 开始扫描文件夹: {target_folder}")
if args.dry_run:
print("==================================================")
print("[警告]: 当前处于 Dry-Run (空跑) 模式,不会修改系统中的任何文件!")
print("==================================================")
if not CIVITAI_API_KEY and not args.offline_only:
print("[!] 未配置 Civitai API Key。部分限制级模型或将无法获取图片。")
write_scan_progress(args.progress_file, "enumerating")
total_files = count_scan_files(target_folder, target_files_basenames)
write_scan_progress(args.progress_file, "scanning", total_files)
current_file = 0
last_progress_write = 0.0
for root, _, files in os.walk(target_folder):
for filename in files:
write_scan_progress(args.progress_file, "enumerating")
total_files = count_scan_files(target_folder, target_files_basenames, args.retry_unmatched)
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}
counts = {"success": 0, "fail": 0, "unchanged": 0}
for root, _, files in os.walk(target_folder):
for filename in files:
if not filename.endswith(".safetensors"):
continue
if target_files_basenames and filename not in target_files_basenames:
continue
current_file += 1
now = time.monotonic()
if current_file == 1 or current_file == total_files or now - last_progress_write >= 0.2:
write_scan_progress(args.progress_file, "scanning", total_files, current_file, filename)
last_progress_write = now
file_path = os.path.join(root, filename)
old_base = os.path.splitext(file_path)[0]
info_exists = os.path.exists(old_base + ".info") or os.path.exists(old_base + ".civitai.info")
if args.force_overwrite:
info_exists = False
preview_exists = args.skip_media
if not preview_exists:
for ext in COVER_SUFFIXES:
if os.path.exists(old_base + ext):
preview_exists = True
break
needs_rename = False
if not args.skip_rename and args.physical_rename:
needs_rename = True
elif args.virtual_rename:
needs_rename = True
if info_exists and preview_exists and not needs_rename:
print(f"[*] 已跳过 (信息满足要求): {filename}")
if target_files_basenames and filename not in target_files_basenames:
continue
previous = read_local_info(os.path.join(root, os.path.splitext(filename)[0]))
if args.retry_unmatched and not is_unmatched(previous):
continue
print(f"\n---> 处理文件: {filename} (位于 {root})")
civitai_data = None
file_hash = None
if info_exists and needs_rename:
info_path = old_base + ".info"
if not os.path.exists(info_path):
info_path = old_base + ".civitai.info"
try:
with open(info_path, 'r', encoding='utf-8') as f:
civitai_data = json.load(f)
print(f"[*] 本地信息存在,直接进入重命名流程")
except:
pass
if not civitai_data:
file_hash = calculate_sha256(file_path)
if not args.offline_only:
civitai_data = fetch_civitai_info(file_hash)
# Fallback 3: Local Offline Inference (if Civitai still fails or offline_only)
if not civitai_data:
if args.skip_local_metadata:
print(f"[*] Civitai 获取失败,且禁用了本地元数据解析。跳过 {filename}")
fail_count += 1
continue
# 尝试离线推断底模
inferred_base = infer_base_model_from_header(file_path)
if inferred_base == 'Unknown':
inferred_base = ""
print(f"[*] 使用本地哈希重建基础元数据 ({filename})")
civitai_data = {
"id": -1,
"modelId": -1,
"name": os.path.splitext(filename)[0],
"baseModel": inferred_base,
"description": "<p>Automatically inferred by Anomalous Local Engine.</p>",
"model": {
"name": os.path.splitext(filename)[0],
"type": "LORA" if "lora" in root.lower() else "Checkpoint"
},
"files": [{"hashes": {"SHA256": file_hash}}]
}
# --- 额外获取模型主页的说明文字 ---
model_id = civitai_data.get("modelId")
if model_id and model_id != -1:
try:
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"
}
if CIVITAI_API_KEY:
headers["Authorization"] = f"Bearer {CIVITAI_API_KEY}"
req = urllib.request.Request(f"https://civitai.com/api/v1/models/{model_id}", headers=headers)
with urllib.request.urlopen(req, timeout=10) as m_resp:
m_data = json.loads(m_resp.read().decode('utf-8'))
if "description" in m_data and m_data["description"]:
civitai_data["description"] = m_data["description"]
if "model" not in civitai_data or not isinstance(civitai_data["model"], dict):
civitai_data["model"] = {}
civitai_data["model"]["description"] = m_data["description"]
except Exception as e:
print(f"[-] 获取模型主页详细说明失败: {e}")
# ----------------------------------
model_name = sanitize_filename(civitai_data.get("model", {}).get("name", "UnknownModel"))
version_name = sanitize_filename(civitai_data.get("name", "UnknownVersion"))
new_filename = f"{model_name}_{version_name}.safetensors"
new_file_path = os.path.join(root, new_filename)
new_base = os.path.splitext(new_file_path)[0]
# ==========================================
# 兼容性大刀阔斧改革:直接保存全宇宙最原汁原味的格式
# ==========================================
info_data = civitai_data
if file_hash:
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
if args.virtual_rename:
info_data["anomalous_custom_name"] = f"{model_name}_{version_name}"
if not args.dry_run:
info_path = old_base + ".info"
with open(info_path, 'w', encoding='utf-8') as f:
json.dump(info_data, f, ensure_ascii=True, indent=4)
print(f"[+] 写入纯净版 Civitai 描述 -> .info")
else:
print(f"[Dry-Run] 拟生成标准描述信息 -> .info")
media_url = None
images = civitai_data.get("images", [])
if images and len(images) > 0:
for img_obj in images:
if not args.skip_media:
if img_obj.get("url"):
media_url = img_obj.get("url")
break
if media_url and not args.skip_media:
if not args.dry_run:
print(f"[*] 正在下载预览媒体...")
saved_path = download_media(media_url, old_base + ".civitai_bak")
if saved_path:
print(f"[+] 媒体下载成功 -> {os.path.basename(saved_path)}")
# Promote to .preview if no custom cover exists
has_custom = False
if not args.force_overwrite:
for c_ext in ACTIVE_COVER_SUFFIXES:
p = old_base + c_ext
if os.path.exists(p) and not p.endswith('.civitai_bak' + c_ext):
has_custom = True
break
if not has_custom:
ext = os.path.splitext(saved_path)[1]
import shutil
if args.force_overwrite:
for c_ext in ACTIVE_COVER_SUFFIXES:
p = old_base + c_ext
if os.path.exists(p) and not p.endswith('.civitai_bak' + c_ext):
try:
os.remove(p)
print(f"[*] 强制覆盖: 已删除旧预览文件 {os.path.basename(p)}")
except:
pass
preview_ext = ext if ext.startswith('.preview.') else f".preview{ext}"
shutil.copy2(saved_path, old_base + preview_ext)
else:
print(f"[Dry-Run] 拟下载预览媒体...")
if args.skip_rename or not args.physical_rename:
print(f"[*] 物理重命名已跳过。仅保存 .info 及其可能包含的虚拟重命名。")
success_count += 1
elif file_path != new_file_path and new_filename != filename:
if os.path.exists(new_file_path):
print(f"[*] 目标文件名已存在,正在验证内容是否完全相同: {filename}")
try:
files_identical = os.path.getsize(file_path) == os.path.getsize(new_file_path)
if files_identical:
files_identical = calculate_sha256(file_path) == calculate_sha256(new_file_path)
except OSError:
files_identical = False
if not files_identical:
print(f"\033[91m[-] 目标名称冲突但文件内容不同,已保留两个模型并跳过重命名: {filename}\033[0m")
fail_count += 1
elif not args.dry_run:
try:
print(f"[*] Hash 一致,删除已确认的重复副本: {filename}")
os.remove(file_path)
for ext in SIDECAR_SUFFIXES:
old_ext = old_base + ext
if os.path.exists(old_ext):
os.remove(old_ext)
success_count += 1
except Exception as e:
print(f"[-] 删除多余副本失败 (可能文件被占用): {e}")
fail_count += 1
else:
print(f"[Dry-Run] Hash 一致,拟删除重复副本及其附属文件: {filename}")
else:
if not args.dry_run:
os.rename(file_path, new_file_path)
rename_log[file_path] = new_file_path
for ext in SIDECAR_SUFFIXES:
old_ext = old_base + ext
new_ext = new_base + ext
if os.path.exists(old_ext):
os.replace(old_ext, new_ext)
print(f"[+] 物理重命名完成: {filename} ==> {new_filename}")
success_count += 1
else:
print(f"[Dry-Run] 拟物理重命名文件: {filename} ==> {new_filename}")
print(f"[Dry-Run] 拟连带重命名附属文件 (.info / .png 等)")
else:
print("[*] 文件名已符合规范,无需重命名。")
success_count += 1
# Save scan results
write_scan_progress(args.progress_file, "complete", total_files, total_files)
if not args.dry_run:
current_file += 1
now = time.monotonic()
if current_file == 1 or current_file == total_files or now - last_progress_write >= 0.2:
write_scan_progress(args.progress_file, "scanning", total_files, current_file, filename)
last_progress_write = now
try:
entry = scan_one(args, state, rename_log, root, filename, previous)
except Exception as e: # one model's trouble does not end the scan of the others
print(f"\033[91m[-] 处理失败,已跳过: {filename}: {e}\033[0m")
entry = {"status": "failed", "error": str(e)[:300]}
if entry is None:
counts["unchanged"] += 1
continue
counts["fail" if entry["status"] == "failed" else "success"] += 1
write_report(args, file=os.path.join(root, filename), **entry)
# Save scan results
write_scan_progress(args.progress_file, "complete", total_files, total_files)
write_report(args, event="done", unchanged=counts["unchanged"], civitai_down=state["civitai_down"])
if not args.dry_run:
result_path = os.path.join(target_folder, ".scan_result.json")
try:
with open(result_path, 'w', encoding='utf-8') as f:
json.dump({"success": success_count, "fail": fail_count}, f)
json.dump({"success": counts["success"], "fail": counts["fail"]}, f)
except Exception as e:
print(f"[-] 保存统计结果失败: {e}")
if not args.dry_run and rename_log:
with open(backup_log_path, 'w', encoding='utf-8') as f:
json.dump(rename_log, f, ensure_ascii=True, indent=4)
print(f"\n[+] 重命名映射日志已保存至: {backup_log_path}")
if __name__ == "__main__":
main()
main()
+88
View File
@@ -0,0 +1,88 @@
// Structure check, run before every commit: node tools/check_structure.mjs
// Fails when a source file is missing from the architecture map, a web module is
// no longer imported from an entry, a new file starts over the size threshold, a
// file already over it grew since the last commit (AGENTS.md section 2), or a web file
// does not parse as the ES module the browser loads.
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const list = (dir, ext) => fs.readdirSync(path.join(ROOT, dir))
.filter((f) => f.endsWith(ext))
.map((f) => path.posix.join(dir, f));
const ENTRIES = ['web/main.js', 'web/hash_resolver.js'];
// Language catalogs and data tables are exempt from the size threshold (AGENTS.md section 2).
const SIZE_EXEMPT = new Set(['web/modules/locales.js']);
const SIZE_LIMIT = 600;
const pythonFiles = [...list('.', '.py'), ...list('api', '.py')].filter((f) => !f.endsWith('__init__.py'));
const webFiles = [...list('web', '.js'), ...list('web/modules', '.js')];
const failures = [];
// 1. Every source file has a line in ARCHITECTURE.md or docs/architecture/.
const docs = ['ARCHITECTURE.md', ...list('docs/architecture', '.md')].map(read).join('\n');
for (const file of [...pythonFiles, ...webFiles]) {
if (!docs.includes(path.posix.basename(file))) failures.push(`${file}: no line in the architecture map`);
}
// 2. Every web module is reachable by imports from an entry.
const IMPORT = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)['"](\.{1,2}\/[^'"]+\.js)['"]/g;
const reached = new Set();
const walk = (file) => {
if (reached.has(file) || !fs.existsSync(path.join(ROOT, file))) return;
reached.add(file);
for (const [, spec] of read(file).matchAll(IMPORT)) walk(path.posix.join(path.posix.dirname(file), spec));
};
ENTRIES.forEach(walk);
for (const file of webFiles) {
if (!reached.has(file)) failures.push(`${file}: not imported from ${ENTRIES.join(' or ')}; remove it or wire it in`);
}
// 3. Size threshold: files over it are listed; a new file may not start over it, and a
// file already over it may only shrink or stay the same (compared with the last commit).
const lineCount = (text) => text.replace(/\r\n/g, '\n').split('\n').length;
const committedLines = (file) => {
try {
return lineCount(execFileSync('git', ['show', `HEAD:${file}`], { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }));
} catch (_) {
return null; // not committed yet (a new file), or no git here
}
};
const large = [...pythonFiles, ...webFiles]
.filter((f) => !SIZE_EXEMPT.has(f))
.map((f) => [f, lineCount(read(f))])
.filter(([, lines]) => lines > SIZE_LIMIT)
.sort((a, b) => b[1] - a[1]);
if (large.length) {
console.log(`Over ${SIZE_LIMIT} lines (may only shrink, AGENTS.md section 2):`);
for (const [file, lines] of large) {
const before = committedLines(file);
const note = before === null ? ' NEW' : lines > before ? ` grew from ${before}` : '';
console.log(` ${lines}\t${file}${note}`);
if (before === null) failures.push(`${file}: new file over ${SIZE_LIMIT} lines; split it by responsibility`);
else if (lines > before) failures.push(`${file}: over ${SIZE_LIMIT} lines and grew from ${before} to ${lines}; move a responsibility out first`);
}
}
// 4. Syntax: every web file must parse as an ES module. `node --check` on a .js file can
// pass what the browser rejects (a raw line break inside a string did), so compile them
// the way the browser does, in one child process.
const PARSE = "const vm = require('node:vm'); const fs = require('node:fs');"
+ " for (const f of process.argv.slice(1)) { try { new vm.SourceTextModule(fs.readFileSync(f, 'utf8')); }"
+ " catch (e) { console.log(f + ' | ' + e.message); } }";
const syntax = execFileSync(process.execPath, ['--experimental-vm-modules', '--no-warnings', '-e', PARSE,
...webFiles.map((file) => path.join(ROOT, file))], { encoding: 'utf8' });
for (const line of syntax.split(/\r?\n/).filter(Boolean)) {
const [file, message] = line.split(' | ');
failures.push(`${path.relative(ROOT, file).split(path.sep).join('/')}: does not parse (${message})`);
}
if (failures.length) {
console.error(`\nStructure check failed:\n ${failures.join('\n ')}`);
process.exit(1);
}
console.log(`\nStructure check passed: ${pythonFiles.length + webFiles.length} source files mapped, ${reached.size} web modules reachable.`);
+53 -377
View File
@@ -1,380 +1,57 @@
import { app } from "../../scripts/app.js";
import { translate } from './modules/locales.js';
import { anomalousConfirm } from './modules/ui_dialog.js';
import { findWorkflowHashRecord } from './modules/recipe_provenance.js';
import {
inferModelFolderTypes,
requiresHashForModelRecovery,
} from './modules/model_policies.js';
// Global cache for hashes: filename -> hash
window.anomalous_hash_cache = window.anomalous_hash_cache || {};
// Function to update cache, can be called from main.js
import { requiresHashForModelRecovery } from './modules/model_policies.js';
import { detectPlatform, isModelFilename } from './modules/model_source_links.js';
import { usableSourceUrl } from './modules/model_source_data.js';
// Global cache for hashes: filename -> hash
window.anomalous_hash_cache = window.anomalous_hash_cache || {};
// Function to update cache, can be called from main.js
window.anomalous_update_hash_cache = function (models) {
if (!models) return;
for (const m of models) {
if (m.filename && m.metadata && m.metadata.hash) {
window.anomalous_hash_cache[m.filename] = {
hash: m.metadata.hash,
size: m.size_bytes || ""
};
}
if (!models) return;
for (const m of models) {
if (m.filename && m.metadata && m.metadata.hash) {
window.anomalous_hash_cache[m.filename] = {
hash: m.metadata.hash,
size: m.size_bytes || ""
};
}
}
};
function isModelFilename(value) {
return typeof value === 'string' && /\.(safetensors|ckpt|pt|bin)$/i.test(value);
/**
* A model's own download link (its information's, the editor's first) for a workflow that
* keeps none of its own for it; `auto` marks it as taken, so a newer one replaces it.
*/
function addOwnSource(sources, nodeId, value, basename, record) {
const url = usableSourceUrl(record?.url);
const kept = sources[value] || sources[basename];
if (!url || (kept && !kept.auto)) return;
sources[value] = { name: value, url, platform: detectPlatform(url)?.name || 'Custom', nodeId, hash: record.hash || '', auto: true };
}
function inferExpectedModelTypes(node, widget) {
return inferModelFolderTypes(node, widget).join(',');
}
window.anomalous_resolve_all_missing_nodes = async function (is_manual = false, silent = false) {
if (!is_manual && localStorage.getItem('anomalous_auto_scan_enabled') !== 'true') return;
if (!app.graph || !app.graph._nodes) return;
// Fast path: if there are no missing nodes and it's not a manual check, abort early to save performance
let has_missing = false;
for (const node of app.graph._nodes) {
if (node.color === "#FF3333" || node.bgcolor === "#FF3333" || node.color === "#f66" || (node.flags && node.flags.collapsed && node.color === "#FF3333")) {
has_missing = true;
break;
}
if (node.widgets) {
for (let i = 0; i < node.widgets.length; i++) {
const w = node.widgets[i];
if (isModelFilename(w.value)) {
if (w.options && w.options.values && !w.options.values.includes(w.value)) {
has_missing = true;
break;
}
}
}
}
if (has_missing) break;
}
if (!has_missing && !is_manual) return;
// Refresh ComfyUI's frontend folder paths cache to prevent false-positive red nodes
if (app.refreshComboInNodes) {
try {
await app.refreshComboInNodes();
} catch (e) {
console.warn("[Anomalous Hash Resolver] Failed to refresh ComfyUI node combos", e);
}
}
const hashes = (app.graph.extra && app.graph.extra.anomalous_hashes) || {};
const getWorkflowHash = (nodeId, val) => {
if (!hashes || typeof val !== 'string') return null;
const normVal = val.replace(/\\/g, '/');
const winVal = val.replace(/\//g, '\\');
return hashes[`${nodeId}_${val}`] ||
hashes[`${nodeId}_${normVal}`] ||
hashes[`${nodeId}_${winVal}`] ||
hashes[val] ||
hashes[normVal] ||
hashes[winVal] ||
null;
};
app.registerExtension({
name: "Anomalous.ModelBrowser.HashResolver",
let needsGlobalHashRefresh = false;
provenanceCheck:
for (const node of app.graph._nodes) {
for (const widget of (node.widgets || [])) {
const value = widget.value;
if (!isModelFilename(value)) continue;
if (!getWorkflowHash(node.id, value)) {
needsGlobalHashRefresh = true;
break provenanceCheck;
async setup() {
// Expose global reload function so scans can trigger it
window.anomalous_reload_hashes = async function () {
try {
const resp = await fetch('/anomalous/all_hashes');
const data = await resp.json();
window.anomalous_hash_cache = data.hashes ? data.hashes : data;
window.anomalous_is_empty_state = Object.keys(window.anomalous_hash_cache).length === 0;
} catch (e) {
window.anomalous_hash_cache = {};
window.anomalous_is_empty_state = true;
console.warn("[Anomalous] Failed to fetch hashes", e);
}
}
}
};
// Provenance-rich workflows already carry the identity data needed by the
// doctor. Refresh the full local filename->hash cache only for legacy items
// that actually need that fallback.
if (needsGlobalHashRefresh) {
try {
const res = await fetch('/anomalous/all_hashes?t=' + Date.now());
const data = await res.json();
const hashesObj = data.hashes ? data.hashes : data;
Object.assign(window.anomalous_hash_cache, hashesObj);
} catch (e) {
console.warn("[Anomalous] Failed to fetch hashes for manual resolution", e);
}
}
// Pre-fetch all hashes on startup so that dragging generated images (without opening UI) still intercepts
await window.anomalous_reload_hashes();
let fixed_count = 0;
// These are per-check results shown by Model Doctor. Clear stale evidence
// before resolving the current graph again.
for (const node of app.graph._nodes) {
for (const widget of (node.widgets || [])) {
delete widget.anomalous_resolution_status;
delete widget.anomalous_size_candidate;
}
}
const findHashData = (node, widget, value) => {
let hashData = getWorkflowHash(node.id, value);
if (!hashData && !requiresHashForModelRecovery(node, widget) && window.anomalous_hash_cache && typeof value === 'string') {
const parts = value.split(/[/\\]/);
const basename = parts[parts.length - 1];
const normVal = value.replace(/\\/g, '/');
const cacheData = window.anomalous_hash_cache[value] || window.anomalous_hash_cache[normVal] || window.anomalous_hash_cache[basename];
if (cacheData) hashData = typeof cacheData === 'string' ? { hash: cacheData, size: "" } : cacheData;
}
return hashData;
};
// Resolve all model references in one request. The backend groups items by
// required model category and scans each category once. Native slash-only
// normalization remains in the existing loop below and needs no disk scan.
const batchItems = [];
for (const node of app.graph._nodes) {
if (!node.widgets) continue;
for (let i = 0; i < node.widgets.length; i++) {
const widget = node.widgets[i];
const value = widget.value;
if (!isModelFilename(value)) continue;
if (widget.options && widget.options.values && !widget.options.values.includes(value)) {
const normalized = value.replace(/\\/g, '/');
const nativeMatch = widget.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normalized);
if (nativeMatch && nativeMatch !== value) continue;
}
const hashData = findHashData(node, widget, value);
if (!hashData) continue;
const hash = typeof hashData === 'string' ? hashData : (hashData.hash || "");
const size = typeof hashData === 'string' ? "" : (hashData.size || "");
if (!hash && !size) continue;
batchItems.push({
key: `${node.id}:${i}:${value}`,
hash,
size,
type: inferExpectedModelTypes(node, widget)
});
}
}
const batchResults = new Map();
if (batchItems.length) {
try {
for (let offset = 0; offset < batchItems.length; offset += 256) {
const batchResponse = await fetch('/anomalous/resolve_hash_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: batchItems.slice(offset, offset + 256) })
});
if (!batchResponse.ok) throw new Error(`HTTP ${batchResponse.status}`);
const batchData = await batchResponse.json();
for (const entry of (batchData.results || [])) batchResults.set(entry.key, entry.result);
}
} catch (error) {
console.warn('[Anomalous Hash Resolver] Batch resolution failed; falling back to single-item requests.', error);
}
}
for (const node of app.graph._nodes) {
if (node.widgets) {
for (let i = 0; i < node.widgets.length; i++) {
const w = node.widgets[i];
const val = w.value;
if (isModelFilename(val)) {
// Native slash mismatch fix: if val isn't in options, but matches if we normalize slashes
if (w.options && w.options.values && !w.options.values.includes(val)) {
const normVal = val.replace(/\\/g, '/');
const exactMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normVal);
if (exactMatch && exactMatch !== val) {
console.log(`[Anomalous Hash Resolver] Fixed slash mismatch: ${val} -> ${exactMatch}`);
w.value = exactMatch;
const wIdx = node.widgets.indexOf(w);
if (wIdx !== -1 && node.widgets_values) {
node.widgets_values[wIdx] = exactMatch;
}
delete node.color;
delete node.bgcolor;
node.has_errors = false;
if (app.lastNodeErrors && app.lastNodeErrors[node.id]) {
delete app.lastNodeErrors[node.id];
}
if (w.callback) {
w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
}
app.graph.setDirtyCanvas(true, true);
fixed_count++; // Mark as fixed so the global graph update is triggered!
continue; // Already fixed natively, skip backend lookup
}
}
const hashData = findHashData(node, w, val);
if (hashData) {
let h = typeof hashData === 'string' ? hashData : hashData.hash;
let s = typeof hashData === 'string' ? "" : (hashData.size || "");
try {
const expectedTypes = inferExpectedModelTypes(node, w);
const typeQuery = expectedTypes ? `&type=${encodeURIComponent(expectedTypes)}` : '';
const batchKey = `${node.id}:${i}:${val}`;
let resData = batchResults.get(batchKey);
if (!batchResults.has(batchKey)) {
const res = await fetch(`/anomalous/resolve_hash?hash=${encodeURIComponent(h)}&size=${encodeURIComponent(s)}${typeQuery}`);
resData = await res.json();
}
if (resData.identity_conflict) {
w.anomalous_resolution_status = 'identity_conflict';
continue;
}
if (resData.confirmation_required) {
w.anomalous_resolution_status = 'size_candidate';
w.anomalous_size_candidate = {
filename: resData.filename,
type: resData.type,
size: resData.size,
};
// Background checks may discover a candidate, but only an
// explicit manual check is allowed to ask for and apply it.
if (!is_manual || silent) continue;
const confirmed = await anomalousConfirm(
translate('doctorSizeCandidatePrompt', {
original: val,
candidate: resData.filename,
size: resData.size ?? s ?? '',
}),
translate('doctorSizeCandidateTitle'),
{
okLabel: translate('doctorUseSizeCandidate'),
cancelLabel: translate('doctorKeepMissing'),
},
);
if (!confirmed) continue;
resData = { ...resData, found: true, manually_confirmed: true };
}
if (resData.found) {
const normVal = val.replace(/\\/g, '/');
const normRes = resData.filename.replace(/\\/g, '/');
let finalValue = resData.filename;
let optionsCacheStale = false;
if (w.options && w.options.values) {
const exactMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes);
if (exactMatch) {
finalValue = exactMatch;
} else {
optionsCacheStale = true;
}
}
if (finalValue !== val || normRes !== normVal || optionsCacheStale || node.has_errors || node.color) {
if (optionsCacheStale) {
console.log(`[Anomalous Hash Resolver] Found ${finalValue} on disk, but ComfyUI frontend dropdown is stale. Forcing backend cache clear...`);
let refreshedMatch = null;
try {
await fetch('/anomalous/clear_cache', { method: 'POST' });
await app.refreshComboInNodes();
// Re-evaluate exact match after refreshing
if (w.options && w.options.values) {
refreshedMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes) || null;
}
} catch (e) {
console.warn("Failed to clear cache:", e);
}
if (!refreshedMatch) {
console.warn(`[Anomalous Hash Resolver] Rejected cross-type or invalid model path for ${node.type}.${w.name}: ${finalValue}`);
continue;
}
finalValue = refreshedMatch;
}
const repairKind = resData.manually_confirmed ? 'Manually confirmed' : 'Auto-fixed';
console.log(`[Anomalous Hash Resolver] ${repairKind} missing model: ${val} -> ${finalValue}`);
w.value = finalValue;
const wIdx = node.widgets.indexOf(w);
if (wIdx !== -1 && node.widgets_values) {
node.widgets_values[wIdx] = finalValue;
}
delete node.color;
delete node.bgcolor;
node.has_errors = false;
node.anomalous_auto_resolved = !resData.manually_confirmed;
node.anomalous_original_missing_val = val;
delete w.anomalous_resolution_status;
delete w.anomalous_size_candidate;
if (w.callback) {
w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
}
app.graph.setDirtyCanvas(true, true);
fixed_count++;
}
}
} catch (err) {
console.error("[Anomalous Hash Resolver] Error:", err);
}
}
}
}
}
}
if (fixed_count > 0) {
// Force ComfyUI v1 side panels (Workflow Overview/Parameters) to re-evaluate and clear errors
if (app.graph && app.graph.change) app.graph.change();
try {
window.dispatchEvent(new CustomEvent("graphChanged"));
} catch (e) { }
// Deep clear ComfyUI native error caches
if (app.lastNodeErrors) {
Object.keys(app.lastNodeErrors).forEach(key => delete app.lastNodeErrors[key]);
}
if (typeof app.clearErrors === 'function') app.clearErrors();
// Note: We removed the aggressive "ghost clicker" that automatically clicked the Refresh button in the Vue side panel.
// It was too intrusive and could cause focus issues. Instead, we now gently remind the user via an alert.
// 注:我们移除了主动点击 Vue 侧边栏刷新按钮的“幽灵连点器”,因为它侵入性过强且容易引发焦点问题。改为在弹窗中进行善意提醒。
}
if (is_manual && !silent) {
if (fixed_count > 0) {
alert(translate('hashResolverFixed', { count: fixed_count }));
}
}
};
app.registerExtension({
name: "Anomalous.ModelBrowser.HashResolver",
async setup() {
// Expose global reload function so scans can trigger it
window.anomalous_reload_hashes = async function () {
try {
const resp = await fetch('/anomalous/all_hashes');
const data = await resp.json();
window.anomalous_hash_cache = data.hashes ? data.hashes : data;
window.anomalous_is_empty_state = Object.keys(window.anomalous_hash_cache).length === 0;
} catch (e) {
window.anomalous_hash_cache = {};
window.anomalous_is_empty_state = true;
console.warn("[Anomalous] Failed to fetch hashes", e);
}
};
// Pre-fetch all hashes on startup so that dragging generated images (without opening UI) still intercepts
await window.anomalous_reload_hashes();
// Intercept graph serialization to inject hashes. ComfyUI 0.27 no
// longer guarantees a global LGraph symbol, so prefer the graph's
// actual constructor and fail closed if the graph API is unavailable.
@@ -413,9 +90,9 @@ app.registerExtension({
const liveSources = (this.extra && this.extra.anomalous_model_sources) ||
(app.graph?.extra && app.graph.extra.anomalous_model_sources) ||
extraObj.anomalous_model_sources || null;
if (liveSources && typeof liveSources === 'object') {
extraObj.anomalous_model_sources = JSON.parse(JSON.stringify(liveSources));
}
// Download links travel with the fingerprints: the workflow's own, else each model's.
const sources = liveSources && typeof liveSources === 'object' ? JSON.parse(JSON.stringify(liveSources)) : {};
const usedNames = new Set();
let unscanned_models = [];
if (data.nodes) {
@@ -425,6 +102,9 @@ app.registerExtension({
if (node.widgets_values && node.widgets_values.length > 0) {
for (const val of node.widgets_values) {
if (typeof val === 'string') {
usedNames.add(val).add(val.replace(/\\/g, '/')).add(val.split(/[/\\]/).pop());
}
if (isModelFilename(val)) {
const parts = val.split(/[/\\]/);
const basename = parts[parts.length - 1];
@@ -455,6 +135,7 @@ app.registerExtension({
if (normVal !== val) {
extraObj.anomalous_hashes[`${node.id}_${normVal}`] = hashObj;
}
addOwnSource(sources, node.id, val, basename, { ...hashObj, url: cache_data.url });
} else {
const preserved = findWorkflowHashRecord(existingProvenance, node.id, val);
if (preserved) {
@@ -484,18 +165,13 @@ app.registerExtension({
}
}
}
// A link for a model no node names any more (replaced, removed) stays behind.
for (const name of Object.keys(sources)) if (!usedNames.has(name)) delete sources[name];
if (Object.keys(sources).length) extraObj.anomalous_model_sources = sources;
else delete extraObj.anomalous_model_sources;
data.extra = extraObj;
window.anomalous_unscanned_models = unscanned_models;
return data;
};
// Intercept loadGraphData to resolve missing models
if (typeof app.loadGraphData === 'function') {
const origLoadGraphData = app.loadGraphData;
app.loadGraphData = function (graphData) {
// Proceed with original loadGraphData synchronously first
return origLoadGraphData.apply(this, arguments);
};
}
}
});
+132 -180
View File
@@ -1,32 +1,26 @@
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('anomalous_btn_x');
localStorage.removeItem('anomalous_btn_y');
}
} catch (_) {}
import { app } from "../../scripts/app.js";
import { createBrowserEntry } from "./modules/browser_entry.js?v=20260921-flicker-free-1";
import { createInterfaceSettings, getCurrentLanguage, setAbyssalScarletTheme, t } from "./modules/interface_settings.js";
export { setAbyssalScarletTheme };
const browserEntry = createBrowserEntry({ translate: t, getCurrentLanguage });
app.registerExtension({
name: "Anomalous.ModelBrowser",
settings: [...browserEntry.settings, ...createInterfaceSettings()],
actionBarButtons: browserEntry.actionBarButtons,
commands: browserEntry.commands,
keybindings: browserEntry.keybindings,
menuCommands: browserEntry.menuCommands,
setup: browserEntry.setup
});
import { app } from "../../scripts/app.js";
import { createBrowserEntry } from "./modules/browser_entry.js";
import { createInterfaceSettings, getCurrentLanguage, setAbyssalScarletTheme, t } from "./modules/interface_settings.js";
import { isModelFilename } from "./modules/model_source_links.js";
export { setAbyssalScarletTheme };
const browserEntry = createBrowserEntry({ translate: t, getCurrentLanguage });
app.registerExtension({
name: "Anomalous.ModelBrowser",
settings: [...browserEntry.settings, ...createInterfaceSettings()],
actionBarButtons: browserEntry.actionBarButtons,
commands: browserEntry.commands,
keybindings: browserEntry.keybindings,
menuCommands: browserEntry.menuCommands,
setup: browserEntry.setup
});
// --- INJECTED WORKFLOW SHARE MODULE ---
// Workflow Share and Preview Modal for Anomalous_Model_Browser
const AMB_WorkflowShare = {
const AMB_WorkflowShare = {
// ----------------------------------------------------------------------
// 1. Data Compression & Base64 Utils
// ----------------------------------------------------------------------
@@ -201,7 +195,7 @@ const AMB_WorkflowShare = {
// ----------------------------------------------------------------------
// 4. Encode / Decode
// ----------------------------------------------------------------------
async encodeShareCode(workflowJson, isSkeleton) {
async encodeShareCode(workflowJson, isSkeleton) {
let targetJson = workflowJson;
if (isSkeleton) {
targetJson = this.skeletonize(workflowJson);
@@ -242,7 +236,7 @@ const AMB_WorkflowShare = {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed; bottom: 30px; right: 30px; background: #2a2a2b; color: ${color || '#fff'};
position: fixed; bottom: 30px; right: 30px; background: var(--amb-bg-card-hover); color: ${color || '#fff'};
padding: 12px 20px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.5);
font-family: Arial, sans-serif; font-size: 14px; z-index: 9999999;
opacity: 0; transition: opacity 0.3s ease; border-left: 4px solid ${color || '#fff'};
@@ -255,214 +249,172 @@ const AMB_WorkflowShare = {
}, 3000);
},
showExportModal() {
const overlay = document.createElement('div');
overlay.id = 'amb-export-modal';
overlay.style.cssText = `
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.6); backdrop-filter: blur(5px);
z-index: 999999; display: flex; justify-content: center; align-items: center;
font-family: Arial, sans-serif;
`;
/** A dimmed overlay with one dialog box; looks come from .anomalous-share-* in the styles. */
createShareDialog(id, titleText, variant = '') {
const overlay = document.createElement('div');
if (id) overlay.id = id;
overlay.className = 'anomalous-share-overlay';
const content = document.createElement('div');
content.style.cssText = `
background: #2a2a2b; color: #fff; padding: 30px; border-radius: 12px;
width: 500px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
display: flex; flex-direction: column; gap: 20px;
`;
content.className = `anomalous-share-dialog${variant ? ` ${variant}` : ''}`;
const title = document.createElement('h2');
title.style.margin = '0';
title.textContent = t('mainExportTitle');
title.className = 'anomalous-share-title';
title.textContent = titleText;
content.appendChild(title);
overlay.appendChild(content);
return { overlay, content };
},
shareButton(label, variant = '') {
const button = document.createElement('button');
button.type = 'button';
button.className = `anomalous-share-btn${variant ? ` ${variant}` : ''}`;
button.textContent = label;
return button;
},
/** What a workflow tells others about its models: how many carry a fingerprint, how many links. */
provenanceSummary(workflow) {
const models = new Set();
for (const node of workflow.nodes || []) {
for (const value of Array.isArray(node.widgets_values) ? node.widgets_values : []) {
if (isModelFilename(value)) models.add(`${node.id}_${value}`);
}
}
const hashes = workflow.extra?.anomalous_hashes || {};
const hashed = [...models].filter(key => hashes[key]?.hash).length;
return { models: models.size, hashed, links: Object.keys(workflow.extra?.anomalous_model_sources || {}).length };
},
provenanceNote(summary) {
if (!summary.models) return '';
if (localStorage.getItem('anomalous_inject_hash') === 'false') return t('mainShareProvenanceOff');
const missing = summary.models - summary.hashed;
return [t('mainShareFingerprints', { hashed: summary.hashed, total: summary.models }),
missing ? t('mainShareNoFingerprint', { count: missing }) : ''].filter(Boolean).join(' ');
},
showExportModal() {
const { overlay, content } = this.createShareDialog('amb-export-modal', t('mainExportTitle'));
const summary = this.provenanceSummary(app.graph.serialize());
const note = document.createElement('p');
note.className = 'anomalous-share-note';
note.textContent = this.provenanceNote(summary);
note.hidden = !note.textContent;
const linksLabel = document.createElement('label');
linksLabel.className = 'anomalous-share-check';
const linksBox = document.createElement('input');
linksBox.type = 'checkbox';
linksBox.checked = true;
linksLabel.append(linksBox, ` ${t('mainShareLinks', { count: summary.links })}`);
linksLabel.hidden = !summary.links;
const typeSelectContainer = document.createElement('div');
typeSelectContainer.className = 'anomalous-share-options';
typeSelectContainer.innerHTML = `
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
<label>
<input type="radio" name="amb-share-type" value="skeleton" checked />
${t('mainSkeletonOption')}
${t('mainSkeletonOption')}
</label>
<label style="display: block; cursor: pointer;">
<label>
<input type="radio" name="amb-share-type" value="full" />
${t('mainFullOption')}
${t('mainFullOption')}
</label>
`;
const textArea = document.createElement('textarea');
textArea.style.cssText = `
width: 100%; height: 150px; background: #1e1e1f; color: #eee;
border: 1px solid #444; border-radius: 6px; padding: 10px;
font-family: monospace; font-size: 12px; resize: none; box-sizing: border-box;
`;
textArea.className = 'anomalous-share-code';
textArea.readOnly = true;
const btnGroup = document.createElement('div');
btnGroup.style.cssText = `display: flex; gap: 10px; justify-content: flex-end;`;
const generateBtn = document.createElement('button');
generateBtn.textContent = t('mainGenerate');
generateBtn.style.cssText = `padding: 8px 16px; background: #4a90e2; color: #fff; border: none; border-radius: 6px; cursor: pointer;`;
const copyBtn = document.createElement('button');
copyBtn.textContent = t('mainCopyClipboard');
copyBtn.style.cssText = `padding: 8px 16px; background: #5cb85c; color: #fff; border: none; border-radius: 6px; cursor: pointer; display: none;`;
const closeBtn = document.createElement('button');
closeBtn.textContent = t('mainClose');
closeBtn.style.cssText = `padding: 8px 16px; background: #555; color: #fff; border: none; border-radius: 6px; cursor: pointer;`;
btnGroup.className = 'anomalous-share-actions';
const generateBtn = this.shareButton(t('mainGenerate'), 'is-primary');
const copyBtn = this.shareButton(t('mainCopyClipboard'));
copyBtn.hidden = true;
const closeBtn = this.shareButton(t('mainClose'), 'is-quiet');
closeBtn.onclick = () => overlay.remove();
generateBtn.onclick = async () => {
const isSkeleton = document.querySelector('input[name="amb-share-type"]:checked').value === 'skeleton';
// Get current workflow from app graph
const p = await app.graphToPrompt();
const workflowJson = p.workflow;
if (!linksBox.checked) delete workflowJson.extra?.anomalous_model_sources;
try {
const code = await AMB_WorkflowShare.encodeShareCode(workflowJson, isSkeleton);
textArea.value = code;
copyBtn.style.display = 'block';
copyBtn.hidden = false;
} catch (err) {
textArea.value = 'Error generating code: ' + err.message;
}
};
copyBtn.onclick = () => {
textArea.select();
document.execCommand('copy');
AMB_WorkflowShare.showToast(t('mainCopied'), '#5cb85c');
AMB_WorkflowShare.showToast(t('mainCopied'), '#5cb85c');
};
btnGroup.appendChild(generateBtn);
btnGroup.appendChild(copyBtn);
btnGroup.appendChild(closeBtn);
content.appendChild(title);
content.appendChild(typeSelectContainer);
content.appendChild(textArea);
content.appendChild(btnGroup);
overlay.appendChild(content);
btnGroup.append(generateBtn, copyBtn, closeBtn);
content.append(typeSelectContainer, note, linksLabel, textArea, btnGroup);
document.body.appendChild(overlay);
},
showImportModal() {
const overlay = document.createElement('div');
overlay.id = 'amb-import-modal';
overlay.style.cssText = `
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.6); backdrop-filter: blur(5px);
z-index: 999999; display: flex; justify-content: center; align-items: center;
font-family: Arial, sans-serif;
`;
const content = document.createElement('div');
content.style.cssText = `
background: #2a2a2b; color: #fff; padding: 30px; border-radius: 12px;
width: 600px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
display: flex; flex-direction: column; gap: 20px;
`;
const title = document.createElement('h2');
title.style.margin = '0';
title.textContent = t('mainImportTitle');
const { overlay, content } = this.createShareDialog('amb-import-modal', t('mainImportTitle'), 'is-wide');
const inputArea = document.createElement('textarea');
inputArea.placeholder = t('mainSharePlaceholder');
inputArea.style.cssText = `
width: 100%; height: 100px; background: #1e1e1f; color: #eee;
border: 1px solid #444; border-radius: 6px; padding: 10px;
font-family: monospace; font-size: 12px; resize: none; box-sizing: border-box;
`;
inputArea.className = 'anomalous-share-code is-input';
inputArea.placeholder = t('mainSharePlaceholder');
const btnGroup = document.createElement('div');
btnGroup.style.cssText = `display: flex; gap: 10px; justify-content: flex-end;`;
const loadBtn = document.createElement('button');
loadBtn.textContent = t('mainImportLoad');
loadBtn.style.cssText = `padding: 8px 16px; background: #e07a5f; color: #fff; border: none; border-radius: 6px; cursor: pointer;`;
const closeBtn = document.createElement('button');
closeBtn.textContent = t('mainCancel');
closeBtn.style.cssText = `padding: 8px 16px; background: #555; color: #fff; border: none; border-radius: 6px; cursor: pointer;`;
btnGroup.className = 'anomalous-share-actions';
const loadBtn = this.shareButton(t('mainImportLoad'), 'is-primary');
const closeBtn = this.shareButton(t('mainCancel'), 'is-quiet');
closeBtn.onclick = () => overlay.remove();
loadBtn.onclick = async () => {
const code = inputArea.value.trim();
if (!code) {
AMB_WorkflowShare.showToast(t('mainShareEmpty'), '#ff6b6b');
AMB_WorkflowShare.showToast(t('mainShareEmpty'), '#ff6b6b');
return;
}
try {
const pendingWorkflow = await AMB_WorkflowShare.decodeShareCode(code);
app.loadGraphData(pendingWorkflow);
overlay.remove();
const nodesCount = pendingWorkflow.nodes ? pendingWorkflow.nodes.length : 0;
AMB_WorkflowShare.showToast(t('mainImportedNodes', { count: nodesCount }), '#5cb85c');
AMB_WorkflowShare.showToast(t('mainImportedNodes', { count: nodesCount }), '#5cb85c');
// Auto close the main browser panel
const mainCloseBtn = document.getElementById('anomalous-close');
if (mainCloseBtn) mainCloseBtn.click();
} catch (err) {
AMB_WorkflowShare.showToast(t('mainDecodeFailed') + err.message, '#ff6b6b');
AMB_WorkflowShare.showToast(t('mainDecodeFailed') + err.message, '#ff6b6b');
}
};
btnGroup.appendChild(loadBtn);
btnGroup.appendChild(closeBtn);
content.appendChild(title);
content.appendChild(inputArea);
content.appendChild(btnGroup);
overlay.appendChild(content);
btnGroup.append(loadBtn, closeBtn);
content.append(inputArea, btnGroup);
document.body.appendChild(overlay);
},
showUnifiedModal() {
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.6); backdrop-filter: blur(5px);
z-index: 999999; display: flex; justify-content: center; align-items: center;
font-family: Arial, sans-serif;
`;
const content = document.createElement('div');
content.style.cssText = `
background: #2a2a2b; color: #fff; padding: 30px; border-radius: 12px;
width: 400px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
display: flex; flex-direction: column; gap: 20px; text-align: center;
`;
const title = document.createElement('h2');
title.style.margin = '0';
title.textContent = t('mainUnifiedTitle');
const exportBtn = document.createElement('button');
exportBtn.textContent = t('mainExportWorkflow');
exportBtn.style.cssText = `padding: 12px; background: #4a90e2; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px;`;
exportBtn.onclick = () => { overlay.remove(); this.showExportModal(); };
const importBtn = document.createElement('button');
importBtn.textContent = t('mainImportWorkflow');
importBtn.style.cssText = `padding: 12px; background: #e07a5f; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px;`;
const { overlay, content } = this.createShareDialog('', t('mainUnifiedTitle'), 'is-menu');
const exportBtn = this.shareButton(t('mainExportWorkflow'), 'is-primary is-large');
exportBtn.onclick = () => { overlay.remove(); this.showExportModal(); };
const importBtn = this.shareButton(t('mainImportWorkflow'), 'is-large');
importBtn.onclick = () => { overlay.remove(); this.showImportModal(); };
const closeBtn = document.createElement('button');
closeBtn.textContent = t('mainClose');
closeBtn.style.cssText = `padding: 8px; background: #555; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 12px; margin-top: 10px;`;
const closeBtn = this.shareButton(t('mainClose'), 'is-quiet');
closeBtn.onclick = () => overlay.remove();
content.appendChild(title);
content.appendChild(exportBtn);
content.appendChild(importBtn);
content.appendChild(closeBtn);
overlay.appendChild(content);
content.append(exportBtn, importBtn, closeBtn);
document.body.appendChild(overlay);
}
};
+72
View File
@@ -0,0 +1,72 @@
/**
* Records the canvas changes Anomalous makes. A pointer press anywhere in Anomalous's
* interface takes a snapshot of the canvas; the next press outside it (or any key
* outside it, or the next press inside) compares the canvas with that snapshot and
* logs the difference, before the user's own edit happens. So every button, drag and
* dialog that writes to nodes is covered without each one reporting itself.
* Opening another workflow meanwhile is logged as that, not as removed nodes. What each
* entry did is handed to canvas_undo.js, so the log can undo it.
*/
import { app } from "../../../scripts/app.js";
import { diffSnapshots, snapshotGraph } from './activity_diff.js';
import { postCanvasActivity } from './activity_log.js';
import { keepUndo, workflowKey as openWorkflowKey } from './canvas_undo.js';
const AMB_UI = '#anomalous-modal, [id^="anomalous-"], [class*="anomalous-"]';
const MAX_SENT = 60;
const currentGraph = () => app.canvas?.graph || app.graph || null;
const activeWorkflow = () => app.extensionManager?.workflow?.activeWorkflow || null;
const workflowName = () => {
const workflow = activeWorkflow();
return String(workflow?.filename || workflow?.path || '');
};
const workflowKey = () => openWorkflowKey(app);
// <html> carries Anomalous classes too (the floating entry setting); it is not our interface.
const insideAnomalous = (target) => {
const hit = target instanceof Element ? target.closest(AMB_UI) : null;
return Boolean(hit) && hit !== document.documentElement && hit !== document.body;
};
/** Starts watching; sets `owner.flushCanvasActivity()` (log what is pending now). */
export function watchCanvasChanges(owner) {
let baseline = null;
const flush = () => {
// A combo group following the pointer is logged once it is put down; Esc removes it.
if (!baseline || owner.placingCombo) return;
const { graph, workflow, nodes } = baseline;
baseline = null;
const now = currentGraph();
if (!now || now !== graph) return; // moved into or out of a subgraph meanwhile
const after = snapshotGraph(now);
const opened = workflowKey() !== workflow;
const changes = opened ? [{ kind: 'opened', node: workflowName(), after: String(after.size) }] : diffSnapshots(nodes, after);
if (!changes.length) return;
postCanvasActivity({ changes: changes.slice(0, MAX_SENT), total: changes.length, workflow: workflowName() })
.then((data) => {
if (!opened) keepUndo(data?.entry?.id, workflow, nodes, after);
owner.onActivityRecorded?.();
})
.catch((error) => console.warn('[AMB] Activity log: canvas change not recorded.', error));
};
const start = () => {
flush();
const graph = currentGraph();
baseline = graph ? { graph, workflow: workflowKey(), nodes: snapshotGraph(graph) } : null;
};
// Capture phase: runs before ComfyUI or Anomalous handle the event.
window.addEventListener('pointerdown', (e) => {
if (insideAnomalous(e.target)) start();
else flush();
}, true);
window.addEventListener('keydown', (e) => {
if (!insideAnomalous(e.target)) flush();
}, true);
owner.flushCanvasActivity = flush;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Canvas snapshots and their difference, for the activity log. Pure: takes a graph-like
* object (`_nodes` or `nodes`, each with `id`, `type`, `title`, `widgets`), no DOM.
*/
/** Text form of a widget value, so values compare and store the same way. */
export function valueText(value) {
if (typeof value === 'string') return value;
try {
return JSON.stringify(value) ?? '';
} catch (_) {
return String(value);
}
}
/**
* `Map(node id → { type, title, widgets: { name → text }, links })`; buttons have no value.
* `links`: the node's input links, so a rewiring shows (canvas_undo.js uses it).
*/
export function snapshotGraph(graph) {
const nodes = new Map();
for (const node of graph?._nodes || graph?.nodes || []) {
if (node?.id === undefined || node.id === null) continue;
const widgets = {};
for (const widget of node.widgets || []) {
if (!widget?.name || widget.type === 'button') continue;
widgets[widget.name] = valueText(widget.value);
}
const links = (node.inputs || []).map(input => input?.link ?? '').join(',');
nodes.set(String(node.id), { type: node.type || '', title: node.title || node.type || '', widgets, links });
}
return nodes;
}
const nodeLabel = (id, node) => `${node.title} #${id}`;
/**
* What changed from `before` to `after` (both from snapshotGraph): widget values
* (`changed`), nodes `added` and `removed`, in canvas order.
*/
export function diffSnapshots(before, after) {
const changes = [];
for (const [id, now] of after) {
const was = before.get(id);
if (!was) {
changes.push({ kind: 'added', node: nodeLabel(id, now), type: now.type });
continue;
}
for (const name of new Set([...Object.keys(was.widgets), ...Object.keys(now.widgets)])) {
if (was.widgets[name] === now.widgets[name]) continue;
changes.push({
kind: 'changed', node: nodeLabel(id, now), type: now.type, widget: name,
before: was.widgets[name] ?? '', after: now.widgets[name] ?? '',
});
}
}
for (const [id, was] of before) {
if (!after.has(id)) changes.push({ kind: 'removed', node: nodeLabel(id, was), type: was.type });
}
return changes;
}
/** The node id in a change's `node` label (`KSampler #12` → `12`), or null. */
export function changeNodeId(change) {
const match = /#([^#\s]+)$/.exec(String(change?.node || ''));
return match ? match[1] : null;
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Activity log client: reading, adding canvas entries, clearing; and the words for
* each entry. The server (`api/activity_log.py`) keeps the list and records file
* changes itself.
*/
import { translate as t } from './locales.js';
import { countsLine, fileOutcome, scanScope, STATUS_MARKS } from './scan_results.js';
// Server action id → locale key.
const ACTION_KEYS = {
model_delete: 'activityModelDelete',
model_edit: 'activityModelEdit',
model_cover: 'activityModelCover',
scan: 'activityScan', // entries from before scans were recorded with their result
recipe_save: 'activityRecipeSave',
recipe_edit: 'activityRecipeEdit',
recipe_delete: 'activityRecipeDelete',
recipe_restore: 'activityRecipeRestore',
recipe_cover: 'activityRecipeCover',
recipe_import: 'activityRecipeImport',
material_save: 'activityMaterialSave',
material_edit: 'activityMaterialEdit',
material_delete: 'activityMaterialDelete',
note_save: 'activityNoteSave',
note_delete: 'activityNoteDelete',
preset_save: 'activityPresetSave',
preset_rename: 'activityPresetRename',
preset_delete: 'activityPresetDelete',
image_delete: 'activityImageDelete',
audio_delete: 'activityAudioDelete',
audio_save: 'activityAudioSave',
cache_clear: 'activityCacheClear',
voice_import: 'activityVoiceImport',
voice_settings: 'activityVoiceSettings',
};
async function request(url, options) {
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
return data;
}
/** Newest first. `source`: 'canvas' | 'file' | undefined (both). */
export async function fetchActivity({ limit = 100, source = '', signal } = {}) {
const params = new URLSearchParams({ limit: String(limit) });
if (source) params.set('source', source);
const data = await request(`/anomalous/activity?${params}`, { signal });
return Array.isArray(data.entries) ? data.entries : [];
}
export function postCanvasActivity(payload) {
return request('/anomalous/activity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
export function clearActivity() {
return request('/anomalous/activity/clear', { method: 'POST' });
}
/** One line saying what an entry did. */
export function entrySummary(entry) {
if (entry.source === 'canvas') {
const changes = entry.detail?.changes || [];
const opened = changes.find(change => change.kind === 'opened');
if (opened) return t('activityCanvasOpened', { name: opened.node || '—', count: opened.after || '?' });
return t('activityCanvasChanged', { count: entry.detail?.total || changes.length });
}
if (entry.action === 'scan_done') {
const scan = entry.detail?.scan || {};
return t('activityScanDone', { scope: scanScope(scan, entry.target), counts: countsLine(scan.counts) });
}
const words = t(ACTION_KEYS[entry.action] || 'activityUnknown');
return entry.target ? t('activityWithTarget', { action: words, target: entry.target }) : words;
}
const FIELD_KEYS = {
custom_name: 'modelSourceFieldName', custom_notes: 'modelSourceFieldNotes',
source_url: 'modelSourceFieldLink', filename: 'activityFieldFilename',
};
/**
* The lines a file entry opens to: [{text, model?}], `model` being a scanned model the line
* can open ({type, path_idx, rel, filename}). Empty for entries without details.
*/
export function fileDetailLines(entry) {
const detail = entry.detail || {};
const empty = t('activityEmptyValue');
const lines = [];
if (detail.scan?.civitai_down) lines.push({ text: t('scanResultCivitaiDown') });
for (const error of detail.scan?.errors || []) lines.push({ text: error });
for (const item of detail.files || []) {
if (typeof item === 'string') lines.push({ text: t('activityFileMoved', { name: item }) });
else lines.push({ text: `${STATUS_MARKS[item.status] || '·'} ${item.filename}: ${fileOutcome(item)}`, model: item.rel ? item : null });
}
for (const field of detail.fields || []) {
lines.push({ text: t('activityFieldChange', {
field: t(FIELD_KEYS[field.field] || field.field), before: field.before || empty, after: field.after || empty,
}) });
}
return lines;
}
/** One line per canvas change (not for `opened`). */
export function changeLine(change) {
const empty = t('activityEmptyValue');
if (change.kind === 'added') return t('activityAdded', { node: change.node });
if (change.kind === 'removed') return t('activityRemoved', { node: change.node });
return t('activityChangedValue', {
node: change.node, widget: change.widget, before: change.before || empty, after: change.after || empty,
});
}
/** Day heading for grouping: today, yesterday, or the date. */
export function dayLabel(seconds, now = new Date()) {
const day = new Date(seconds * 1000);
const start = date => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const diff = Math.round((start(now) - start(day)) / 86400000);
if (diff === 0) return t('activityToday');
if (diff === 1) return t('activityYesterday');
return day.toLocaleDateString();
}
export function timeLabel(seconds) {
return new Date(seconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
+231
View File
@@ -0,0 +1,231 @@
import { TTS_NODE_CLASS } from './audio_script.js';
/**
* The speech engine the audio page manages: GPT-SoVITS through the Anomalous_TTS
* node pack. Anomalous never bundles it; it is detected at runtime, and a missing
* pack only changes what the audio page shows.
*
* Characters are normalised into "voice groups" that the studio cards, sidebar
* and Script Director share:
* { group, character, language, has_main, node_value, total_slices, raw,
* slices: [{ emotion, is_main, text, audio_url, syntax_tag, relative_path }] }
* `node_value` is what goes into the node's `character` widget.
*/
export const TTS_ENGINE = Object.freeze({
label: 'GPT-SoVITS',
pack: 'Anomalous_TTS',
repoUrl: 'https://github.com/DemonGatanjieu/Anomalous_TTS',
managerSearch: 'Anomalous TTS',
});
/** The voice group (and audio-page filter value) of a GPT-SoVITS character. */
export function voiceGroupKey(name) {
return `gpt_sovits:${name}`;
}
// ---------- normalisation ----------
export function ttsAudioUrl(name, path) {
return `/anomalous_tts/audio?character=${encodeURIComponent(name)}&path=${encodeURIComponent(path)}`;
}
function ttsSlice(name, emotion, ref, isMain) {
return {
id: `${name}:${emotion}`,
character: name,
emotion,
is_main: isMain,
filename: String(ref.audio || '').split('/').pop(),
relative_path: ref.audio || '',
text: ref.text || '',
synthesis_text: ref.text || '',
language: ref.language || '',
source: ref.source || '',
syntax_tag: `{${emotion}}`,
audio_url: ref.audio ? ttsAudioUrl(name, ref.audio) : '',
};
}
/** Anomalous_TTS `GET /anomalous_tts/characters` → voice groups (docs: the node repo's docs/INTERFACE.md). */
export function gptSovitsGroups(payload) {
return (payload?.characters || []).filter(item => item && typeof item.name === 'string').map(item => {
const name = item.name;
const slices = [];
if (item.reference?.audio) slices.push(ttsSlice(name, 'main', item.reference, true));
for (const [emotion, ref] of Object.entries(item.emotions || {})) {
if (emotion !== 'main' && ref?.audio) slices.push(ttsSlice(name, emotion, ref, false));
}
return {
group: voiceGroupKey(name),
character: name,
language: item.language || '',
aliases: Array.isArray(item.aliases) ? item.aliases : [],
error: item.error || item.settings_error || '',
has_main: Boolean(item.reference?.audio) && !item.error,
node_value: name,
total_slices: slices.length,
slices,
raw: item,
};
}).sort((a, b) => (b.has_main - a.has_main) || a.character.localeCompare(b.character));
}
// ---------- detection + loading ----------
//
// Switching to the audio domain, clicking a sidebar entry or saving a setting all
// re-render the studio and the sidebar. They must not hit the server each time:
// `/object_info/<class>` makes ComfyUI rebuild that node's inputs (a folder scan),
// and a character list can be large. So both results are kept until something
// says they are stale: the Refresh button, a save, an added voice, or MAX_AGE_MS.
const MAX_AGE_MS = 60_000;
let installCache = null; // { at, promise } — node pack presence
let loadCache = null; // { at, promise } — the character list
let rescanNext = false;
async function getJson(url, signal) {
const resp = await fetch(url, { signal });
const data = await resp.json().catch(() => null);
return { ok: resp.ok, status: resp.status, data };
}
/** Installed = ComfyUI knows the node class (`/object_info/<class>` is `{}` otherwise). */
async function isNodeInstalled(nodeClass) {
try {
const { ok, data } = await getJson(`/object_info/${encodeURIComponent(nodeClass)}`);
return ok && Boolean(data && data[nodeClass]);
} catch (_) {
return false;
}
}
function fresh(entry) {
return entry && Date.now() - entry.at < MAX_AGE_MS;
}
async function loadGroups() {
// The node caches its folder scan; `refresh=1` makes it look at the disk again.
const url = rescanNext ? '/anomalous_tts/characters?refresh=1' : '/anomalous_tts/characters';
rescanNext = false;
const { ok, status, data } = await getJson(url);
if (!ok || !data) throw new Error(`HTTP ${status}`);
return gptSovitsGroups(data);
}
/**
* Forget cached engine data. `rescan: true` (the Refresh button) also asks the
* node to re-read its folders instead of answering from its own cache.
*/
export function invalidateEngineCache({ rescan = false } = {}) {
loadCache = null;
installCache = null;
statusCache = null;
if (rescan) rescanNext = true;
}
let statusCache = null; // { at, promise } — GPT-SoVITS setup status
/**
* The GPT-SoVITS node's setup status (libraries, pretrained files, packages; interface v3),
* or null for a node that predates it (no `/anomalous_tts/status`). Cached like loadVoices;
* `force` skips the cache (download progress polling).
*/
export function loadGptSovitsStatus({ force = false } = {}) {
if (!force && fresh(statusCache)) return statusCache.promise;
const promise = getJson('/anomalous_tts/status').then(({ ok, status, data }) => {
if (status === 404) return null;
if (!ok || !data) throw new Error(`HTTP ${status}`);
return data;
});
statusCache = { at: Date.now(), promise };
promise.catch(() => { if (statusCache?.promise === promise) statusCache = null; });
return promise;
}
/**
* { installed, groups, error }: the GPT-SoVITS characters, none while the node pack
* is missing. Concurrent callers (studio + sidebar) share one request.
*/
export function loadVoices() {
if (fresh(loadCache)) return loadCache.promise;
const promise = loadVoicesNow();
loadCache = { at: Date.now(), promise };
// A failure must not stick for a minute.
promise.then(result => { if (result.error && loadCache?.promise === promise) loadCache = null; });
return promise;
}
async function loadVoicesNow() {
const installed = await isTtsInstalled();
if (!installed) return { installed, groups: [], error: '' };
try {
return { installed, groups: await loadGroups(), error: '' };
} catch (e) {
return { installed, groups: [], error: e.message || String(e) };
}
}
/** Whether ComfyUI has the Anomalous_TTS node. Cached like loadVoices. */
export function isTtsInstalled() {
if (fresh(installCache)) return installCache.promise;
const promise = isNodeInstalled(TTS_NODE_CLASS);
installCache = { at: Date.now(), promise };
return promise;
}
/**
* One GPT-SoVITS character with its file lists (`gpt`, `sovits`, `audio`), which the
* summary list leaves out because a folder can hold thousands of clips.
*/
export async function fetchGptSovitsCharacter(name, signal) {
const { ok, status, data } = await getJson(`/anomalous_tts/characters?name=${encodeURIComponent(name)}`, signal);
if (!ok || !data?.character) throw new Error(status === 404 ? `404 ${name}` : `HTTP ${status}`);
return data.character;
}
// ---------- GPT-SoVITS settings (Anomalous writes, the node reads) ----------
/**
* Replace a character's anomalous_tts.json through the node's API. `settings` must be the
* full object (unknown fields preserved by the caller). Returns the refreshed group.
*/
export async function saveGptSovitsSettings(name, settings) {
const resp = await fetch('/anomalous_tts/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ character: name, settings }),
});
if (!resp.ok) {
const message = await resp.text().catch(() => '');
throw new Error(message || `HTTP ${resp.status}`);
}
const data = await resp.json().catch(() => ({}));
invalidateEngineCache();
return data.character ? gptSovitsGroups({ characters: [data.character] })[0] : null;
}
/**
* Build the new settings for an emotion editor result without dropping fields
* Anomalous does not know. `main` = { audio, text } | null; `emotions` = [{ name, audio, text }].
*/
export function mergeGptSovitsSettings(previous, { main, emotions }) {
const next = { ...(previous || {}), format: 1 };
if (main?.audio) {
const kept = previous?.reference && typeof previous.reference === 'object' ? previous.reference : {};
next.reference = { ...kept, audio: main.audio };
if (main.text) next.reference.text = main.text; else delete next.reference.text;
} else {
delete next.reference;
}
const oldEmotions = previous?.emotions && typeof previous.emotions === 'object' ? previous.emotions : {};
const out = {};
for (const row of emotions || []) {
const name = String(row?.name || '').trim();
if (!name || name === 'main' || !row.audio) continue;
const kept = oldEmotions[name] && typeof oldEmotions[name] === 'object' ? oldEmotions[name] : {};
out[name] = { ...kept, audio: row.audio };
if (row.text) out[name].text = row.text; else delete out[name].text;
}
if (Object.keys(out).length) next.emotions = out; else delete next.emotions;
return next;
}
+40
View File
@@ -0,0 +1,40 @@
import { comboValueForPath } from './audio_script.js';
/**
* The only canvas nodes the audio studio writes into. No guessing by widget name:
* a node that is not listed here is refused. Each node picks a character in
* `voiceWidget` (only values its own dropdown offers, in its spelling) and
* switches emotions itself through `{emotion}` tags in `speechWidget`, where the
* Script Director writes the tagged script.
*
* To support a new node, add one entry and a test; nothing else changes.
*/
export const AUDIO_NODE_TARGETS = Object.freeze([
Object.freeze({
id: 'anomalous-tts',
label: 'GPT-SoVITS',
types: Object.freeze(['AnomalousTTS_CharacterSpeech']),
voiceWidget: 'character',
speechWidget: 'text',
}),
]);
export function targetForNode(node) {
const type = node?.type || node?.comfyClass;
return AUDIO_NODE_TARGETS.find(target => target.types.includes(type)) || null;
}
/**
* Decide what dropping the character `group` on `node` does, without touching the node.
* Returns { ok: true, target, widget, value } or { ok: false, reason, target?, path? }.
*/
export function planVoiceDrop(node, group) {
const target = targetForNode(node);
if (!target) return { ok: false, reason: 'unsupported' };
if (!group?.has_main) return { ok: false, reason: 'noMainVoice', target };
const widget = node.widgets?.find(item => item.name === target.voiceWidget);
if (!widget) return { ok: false, reason: 'unsupported', target };
const value = comboValueForPath(widget, group.node_value);
if (value == null) return { ok: false, reason: 'notListed', target, path: group.node_value };
return { ok: true, target, widget, value };
}
+171
View File
@@ -0,0 +1,171 @@
/**
* Pure script rules for GPT-SoVITS (Anomalous_TTS) scripts (no DOM, no canvas access).
*
* The node takes a character and a script in which `{happy}` switches to that
* emotion's reference, `{main}` back to the main one, and `[take:N]` asks for
* another take of one line. `buildTtsPrompt` runs a script without a canvas.
*/
export const MAIN_TAG = '{main}';
const normalisePath = value => String(value ?? '').replace(/\\/g, '/');
/** Return the widget's own spelling of `relativePath` (slashes may differ on Windows), or null when not offered. */
export function comboValueForPath(widget, relativePath) {
const wanted = normalisePath(relativePath);
if (!wanted) return null;
const values = Array.isArray(widget?.options?.values) ? widget.options.values : null;
if (!values) return wanted;
return values.find(value => normalisePath(value) === wanted) ?? null;
}
const SENTENCE_END = new Set(['。', '!', '?', '!', '?']);
// Closing quotes/brackets and trailing marks that belong to the sentence they follow.
const TRAILING = new Set(['。', '!', '?', '!', '?', '」', '』', '”', '’', '"', "'", ')', ')', '】', '》', '〉', '~', '~']);
const SPEAKABLE = /[\p{L}\p{N}]/u;
function splitSentences(line) {
const chars = [...line];
const segments = [];
let current = '';
for (let i = 0; i < chars.length; i++) {
const char = chars[i];
current += char;
// A single '.' ends an English sentence only before whitespace; '3.5', '...' and '……' do not split.
const isPeriod = char === '.' && chars[i - 1] !== '.' && chars[i + 1] !== '.' && (i + 1 >= chars.length || /\s/.test(chars[i + 1]));
if (!SENTENCE_END.has(char) && !isPeriod) continue;
while (i + 1 < chars.length && TRAILING.has(chars[i + 1])) current += chars[++i];
segments.push(current);
current = '';
}
segments.push(current);
return segments;
}
/** Punctuation-only pieces ("……", "!」") cannot be spoken on their own: attach them to a neighbour. */
function attachUnspeakable(segments) {
const result = [];
let pending = '';
for (const segment of segments) {
if (SPEAKABLE.test(segment)) {
result.push(pending + segment);
pending = '';
} else if (result.length) {
result[result.length - 1] += segment;
} else {
pending += segment;
}
}
if (pending) result.push(pending);
return result;
}
/**
* Split a script into segments. `sentence`: at 。!?!? (with trailing quotes or
* brackets) and newlines; ellipses never split. `line`: one segment per line.
*/
export function splitScriptLines(text, mode = 'sentence') {
const lines = String(text || '').split(/\n+/).map(line => line.trim()).filter(Boolean);
const segments = mode === 'line' ? lines : lines.flatMap(splitSentences);
return attachUnspeakable(segments.map(segment => segment.trim()).filter(Boolean));
}
/** Split one segment at a caret position; null when either side would be empty. */
export function splitSegmentAt(text, position) {
const value = String(text || '');
const before = value.slice(0, position).trim();
const after = value.slice(position).trim();
return before && after ? [before, after] : null;
}
/** Join two segments: a space only between Latin words/digits, none for CJK text. */
export function joinSegments(first, second) {
const a = String(first || '').trim();
const b = String(second || '').trim();
if (!a || !b) return a || b;
return /[A-Za-z0-9,.;:!?]$/.test(a) && /^[A-Za-z0-9]/.test(b) ? `${a} ${b}` : `${a}${b}`;
}
/**
* Once any line is tagged, untagged lines get {main} instead of inheriting the previous voice.
* A line with `take` > 1 (GPT-SoVITS only) gets `[take:N]` right before its text.
*/
export function composeScript(lines) {
const anyTagged = lines.some(line => line.voice?.tag);
return lines
.map(line => {
const tag = line.voice?.tag || (anyTagged ? MAIN_TAG : '');
const text = line.take > 1 ? `[take:${line.take}]${line.text}` : line.text;
return tag ? `${tag} ${text}` : text;
})
.join('\n')
.trim();
}
/** Emotions of a voice group the node can switch to with `{emotion}` (main first). */
export function usableEmotions(group) {
return (group?.slices || []).map(slice => slice.emotion);
}
/**
* Bundle script lines for one TTS node. `lines` are `{ text, emotion }`; the node's
* `character` widget gets the group's `node_value` and every emotion must be usable
* for that group. `takes` (runs in the director) writes each line's `take` as `[take:N]`.
* Returns `{ sample, speech, lineCount }` or `{ error: localeKey }`.
*/
export function buildScriptPackage(lines, group, { takes = false } = {}) {
if (!group) return { error: 'scriptDirectorPickCharacter' };
const usable = new Set(usableEmotions(group));
const voice = group.node_value;
if (!voice || group.has_main === false || !usable.has('main')) return { error: 'scriptDirectorMainMissing' };
const voiced = [];
for (const line of lines || []) {
const text = String(line?.text || '').trim();
if (!text) continue;
const emotion = line.emotion || 'main';
if (!usable.has(emotion)) return { error: 'scriptDirectorVoiceUnusable' };
voiced.push({ text, voice: emotion === 'main' ? null : { tag: `{${emotion}}` }, take: takes ? line.take || 1 : 1 });
}
if (!voiced.length) return { error: 'scriptDirectorEmpty' };
return { sample: voice, speech: composeScript(voiced), lineCount: voiced.length };
}
/** Save Audio prefix for a character: `output/audio/<character>/…`, keeping file names Windows accepts. */
export function ttsOutputPrefix(character) {
const parts = String(character || '').split('/')
.map(part => part.replace(/[<>:"\\|?*\u0000-\u001f]/g, '_').trim().replace(/^\.+$/, '_') || '_');
return `audio/${parts.join('/')}/${parts[parts.length - 1]}`;
}
export const TTS_NODE_CLASS = 'AnomalousTTS_CharacterSpeech';
/**
* ComfyUI API prompt for one GPT-SoVITS script: the character node, then Save Audio
* (`preview: true`: Preview Audio, a temp file that stays out of the audio gallery).
* `inputs` are more node inputs by name (speed, top_k …); left out, the node's defaults apply.
*/
export function buildTtsPrompt({ character, speech, seed, language = 'auto', preview = false, ...inputs }) {
return {
1: {
class_type: TTS_NODE_CLASS,
inputs: { character, text: speech, seed, language, ...inputs },
},
2: preview
? { class_type: 'PreviewAudio', inputs: { audio: ['1', 0] } }
: { class_type: 'SaveAudio', inputs: { audio: ['1', 0], filename_prefix: ttsOutputPrefix(character) } },
};
}
/**
* Read a tagged script back into segments, splitting at `{emotion}` tags.
* Text before the first tag belongs to the main voice.
*/
export function parseTaggedSpeech(speech) {
return String(speech || '')
.split(/(?=\{[^}]+\})/)
.map(part => {
const match = /^\{([^}]+)\}/.exec(part);
return { emotion: match ? match[1].trim() : 'main', text: (match ? part.slice(match[0].length) : part).trim() };
})
.filter(segment => segment.text);
}
+110
View File
@@ -0,0 +1,110 @@
import { api } from '../../../scripts/api.js';
/**
* Run one ComfyUI API prompt from the audio page without touching the canvas:
* POST /prompt with this page's client id, live status from the queue's
* websocket events, the result from /history (polled too, so a missed event
* cannot leave the job hanging). ComfyUI runs it in the same queue as the
* user's own workflows.
*/
const POLL_MS = 1500;
const JSON_HEADERS = { 'Content-Type': 'application/json' };
/** A refused prompt's reason, from ComfyUI's validation answer. */
function refusalMessage(data) {
const nodeErrors = Object.values(data?.node_errors || {}).flatMap(entry => entry?.errors || []);
const details = nodeErrors.map(error => error.details || error.message).filter(Boolean);
return details.join('; ') || data?.error?.message || '';
}
function historyError(record) {
const messages = record?.status?.messages || [];
const failure = messages.find(([type]) => type === 'execution_error');
if (failure) return failure[1]?.exception_message || 'error';
return messages.some(([type]) => type === 'execution_interrupted') ? null : 'error';
}
/**
* Queue `prompt` and follow it. `onStatus({ state: 'queued' | 'running', value?, max? })`.
* Returns `{ result, cancel }`: `result` resolves with `{ outputs, promptId }` (the history
* outputs by node id) and rejects with an Error (`cancelled: true` when stopped).
*/
export function startPromptJob(prompt, { onStatus } = {}) {
let promptId = null;
let running = false;
let finished = false;
let timer = null;
let settle;
const result = new Promise((resolve, reject) => { settle = { resolve, reject }; });
const listeners = [];
const finish = (error, value) => {
if (finished) return;
finished = true;
clearTimeout(timer);
listeners.forEach(([type, handler]) => api.removeEventListener(type, handler));
if (error) settle.reject(error); else settle.resolve(value);
};
const cancelled = () => Object.assign(new Error('cancelled'), { cancelled: true });
const status = next => { if (!finished) onStatus?.(next); };
// ComfyUI may start the job and send its first events before /prompt has answered with
// the id, so events that arrive before the id is known are kept and replayed.
const early = [];
const listen = (type, handler) => {
const wrapped = event => {
const detail = event?.detail;
if (!detail?.prompt_id) return;
if (promptId === null) early.push([detail, handler]);
else if (detail.prompt_id === promptId) handler(detail);
};
api.addEventListener(type, wrapped);
listeners.push([type, wrapped]);
};
listen('execution_start', () => { running = true; status({ state: 'running' }); });
listen('progress', detail => { running = true; status({ state: 'running', value: detail.value, max: detail.max }); });
listen('execution_error', detail => finish(new Error(detail.exception_message || 'error')));
listen('execution_interrupted', () => finish(cancelled()));
const poll = async () => {
if (finished) return;
try {
const history = await (await fetch(`/history/${encodeURIComponent(promptId)}`)).json();
const record = history?.[promptId];
if (record?.status?.completed) return finish(null, { outputs: record.outputs || {}, promptId });
if (record?.status?.status_str === 'error') {
const message = historyError(record);
return finish(message ? new Error(message) : cancelled());
}
} catch (_) {
// A dropped request is retried on the next tick.
}
timer = setTimeout(poll, POLL_MS);
};
(async () => {
const resp = await fetch('/prompt', { method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({ prompt, client_id: api.clientId }) });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.prompt_id) throw new Error(refusalMessage(data) || `HTTP ${resp.status}`);
promptId = data.prompt_id;
status({ state: 'queued' });
early.splice(0).forEach(([detail, handler]) => { if (detail.prompt_id === promptId) handler(detail); });
poll();
})().catch(error => finish(error));
/** Remove the job from the queue, or interrupt it when it is the one running. */
const cancel = async () => {
if (finished || !promptId) return;
// The start event may not have arrived yet: ask the queue before choosing.
const queue = running ? null : await fetch('/queue').then(resp => resp.json()).catch(() => null);
if (running || queue?.queue_running?.some(item => item?.[1] === promptId)) {
await fetch('/interrupt', { method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({ prompt_id: promptId }) }).catch(() => {});
} else {
await fetch('/queue', { method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({ delete: [promptId] }) }).catch(() => {});
finish(cancelled());
}
};
return { result, cancel };
}
+45
View File
@@ -0,0 +1,45 @@
import { app } from '../../../scripts/app.js';
import { t } from './interface_settings.js';
import { AUDIO_NODE_TARGETS, planVoiceDrop } from './audio_node_targets.js';
import { TTS_ENGINE } from './audio_engines.js';
import { bindMaterialDrag } from './material_drag.js';
/**
* Dragging a character onto a canvas node, shared by the studio cards and the
* audio sidebar. Which nodes take it is decided by audio_node_targets.js;
* unlisted nodes are refused, never guessed.
*/
/** Why a hovered node is refused; every refusal names what to do instead. */
function dropRejectHint(node, group) {
const plan = planVoiceDrop(node, group);
if (plan.ok) return '';
return t(`audioDropReject_${plan.reason}`, {
node: plan.target?.label || '',
supported: AUDIO_NODE_TARGETS.map(target => target.label).join(t('audioListSeparator')),
character: group.character,
file: plan.path || '',
});
}
export function bindVoiceDrag(element, group, owner) {
bindMaterialDrag(element, owner || {}, {
payload: () => ({
group,
dragHint: t('audioDragCharacterHint', { character: group.character, node: TTS_ENGINE.label }),
}),
accepts: node => planVoiceDrop(node, group).ok,
targetHint: node => {
const plan = planVoiceDrop(node, group);
return plan.ok ? t('audioDropCharacterTarget', { node: plan.target.label, character: group.character }) : '';
},
rejectHint: node => dropRejectHint(node, group),
drop: async node => {
const plan = planVoiceDrop(node, group);
if (!plan.ok) return;
plan.widget.value = plan.value;
plan.widget.callback?.(plan.value, app.canvas, node);
node.setDirtyCanvas?.(true, true);
},
});
}
+97 -22
View File
@@ -4,23 +4,29 @@ import { _openAdvancedModelSelector, setWidgetValuePath } from './ui_model_selec
import { loadModels, applyModelToCanvas, stopMediaInContainer } from './ui_grid.js';
import { createDOM, renderSidebar, loadFolders } from './ui_sidebar.js';
import { closeWorkspace, hideAllPanels } from './ui_browser_navigation.js';
import { openScanWizard, triggerDirectModelScan } from './ui_scan_wizard.js';
import { openFolderManager } from './ui_folder_manager.js';
import { showHelp } from './ui_help.js';
import { triggerDirectModelScan } from './scan_runner.js';
import { openScanPage, renderScanPage, leaveScanPage } from './ui_scan_page.js';
import { loadGalleryImages, refreshGalleryImages, showGeneratedGallery, showGallerySelectMode, showGalleryViewer } from './ui_gallery.js';
import { showNotebooks, refreshNotebooks, saveCurrentNotebook, deleteCurrentNotebook } from './ui_notebooks.js';
import { showNotebooks, openCombo, openComboByFilename, refreshNotebooks, saveCurrentNotebook, deleteCurrentNotebook } from './ui_notebooks.js';
import { renderNotebookEditor, fillNotebookGalleries } from './ui_notebook_editor.js';
import { sendNotebookToCanvas } from './notebook_canvas.js';
import { showRecipes, refreshRecipes } from './ui_recipe_catalog.js';
import { renderRecipeList, handleSaveRecipe } from './ui_recipes.js';
import { showMaterials, refreshMaterials, openSavedMaterial, openMaterialLibrary } from './ui_materials.js';
import { renderRecipeList, handleSaveRecipe, openRecipeByFilename } from './ui_recipes.js';
import { openPromptStudio } from './ui_prompt_composer.js';
import { openPromptTranslator } from './ui_prompt_translator.js';
import { closeUpdateGuide } from './ui_update_guide.js';
import { showImageWorkbench } from './ui_gallery_detail.js';
import { initDoctorPanel, diagnoseNode, renderGlobalDashboard, openLoraInsertionPicker, runGlobalDoctorScan } from './ui_doctor.js';
import { initAssistantPanel, renderAssistantModelCard, _loadAssistantHistory } from './ui_node_assistant.js';
import { openDoctorPage } from './ui_doctor.js';
import { showModelSources } from './ui_model_sources.js';
import { initAssistantPanel, renderAssistantModelCard, _loadAssistantHistory, diagnoseNode, openCurrentNode, openLoraInsertionPicker } from './ui_node_assistant.js';
import { _openGalleryReplacer } from './ui_node_model_picker.js';
import { renderAudioStudio, stopAudioStudioPlayback } from './ui_audio_studio.js';
import { renderAudioGallery, stopGalleryAudio } from './ui_audio_gallery.js';
import { renderScriptPage } from './ui_script_page.js';
import { getActiveAudioFilter, setActiveAudioFilter, syncAudioSidebarSelection } from './ui_audio_sidebar.js';
import { voiceGroupKey } from './audio_engines.js';
import { getActiveDomain } from './ui_domain_switcher.js';
import { startPage } from './ui_shell_nav.js';
import { followComfyLanguage } from './interface_settings.js';
export class AnomalousBrowser {
constructor() {
@@ -33,7 +39,8 @@ export class AnomalousBrowser {
this.currentSubfolder = '/';
this.foldersData = null;
this.expandedFolders = new Set(['/', 'checkpoints', 'loras', 'unet', 'diffusion_models']);
this.energySaving = localStorage.getItem('anomalous_energy_saving') === 'true';
// Video covers play on hover unless you chose "always": fewer videos decoding at once.
this.energySaving = localStorage.getItem('anomalous_energy_saving') !== 'false';
this.cardThumbnailMode = localStorage.getItem('anomalous_card_thumbnail_mode') === 'original'
? 'original'
: 'balanced';
@@ -42,23 +49,92 @@ export class AnomalousBrowser {
}
show() {
followComfyLanguage();
if (this._idleReleaseTimer) {
clearTimeout(this._idleReleaseTimer);
this._idleReleaseTimer = null;
}
this.setTriggerVisible(false);
this.modal.classList.add('visible');
if (!this.foldersData) {
if (!this.currentShellPage()) {
this.goTo(startPage());
return;
}
// Opened again: stay on the page, refresh what may have changed meanwhile.
if (getActiveDomain() === 'audio') {
const audioTabs = { 'audio-gallery': 'gallery', script: 'script' };
this.switchAudioTab(audioTabs[this.currentShellPage()] || 'presets');
} else if (!this.foldersData) {
this.loadFolders();
} else {
this.loadModels();
}
if (this.currentShellPage() === 'gallery') void this.refreshGalleryImages();
if (this.currentShellPage() === 'scan') void renderScanPage(this, this.scanPanel);
}
/** The image / audio switch: the first page of that side. */
handleDomainChange(domain) {
this.goTo(domain === 'audio' ? 'voices' : 'models');
}
/** Single entry for audio-domain navigation: the rail, the audio list's entries and the domain switch. */
switchAudioTab(tabName, filter = null) {
if (!(tabName === 'presets' && filter?.type === 'group' && this.currentShellPage() === 'script')) this.hideAllPanels();
if (tabName === 'gallery') {
setActiveAudioFilter({ type: 'gallery', value: null });
this.markShellPage?.('audio-gallery');
this.audioGalleryPanel.style.display = 'block';
renderAudioGallery(this.audioGalleryPanel, { owner: this });
} else if (tabName === 'script') {
this.markShellPage?.('script');
this.scriptPanel.style.display = 'flex';
const pending = this.pendingScript || {};
this.pendingScript = null;
renderScriptPage(this.scriptPanel, this, pending);
} else if (filter?.type === 'group' && this.currentShellPage() === 'script') {
// A character picked in the list while on Voice-over: it speaks there.
this.openScript(filter.value);
return;
} else {
if (filter) setActiveAudioFilter(filter);
else if (getActiveAudioFilter().type === 'gallery') setActiveAudioFilter(null);
this.markShellPage?.('voices');
this.audioStudioPanel.style.display = 'block';
renderAudioStudio(this.audioStudioPanel, { owner: this });
}
syncAudioSidebarSelection(this);
}
/** One character's voice card: the Anomalous_TTS node's "import or edit characters". */
openVoice(character) {
const filter = character ? { type: 'group', value: voiceGroupKey(character), character } : null;
if (this.currentShellPage() === 'voices') {
this.switchAudioTab('presets', filter);
return;
}
if (filter) setActiveAudioFilter(filter);
this.goTo('voices'); // keeps the filter just set
}
/**
* The Voices page's Voice-over view. `group`: a voice group key to choose (a card, the list);
* `script`: `{ speech, subfolder }` of a generated file to voice again (the audio gallery).
*/
openScript(group = null, script = null) {
this.pendingScript = { character: group, script };
if (this.currentShellPage() === 'script') this.switchAudioTab('script');
else this.goTo('script');
}
close() {
this.flushCanvasActivity?.();
closeUpdateGuide(this);
leaveScanPage(); // a running scan's progress floats over the canvas
this.modal.classList.remove('visible');
this.setTriggerVisible(true);
stopAudioStudioPlayback();
stopGalleryAudio();
const canvas = document.getElementById('graph-canvas');
if (canvas instanceof HTMLElement) canvas.focus({ preventScroll: true });
if (this._modelLoadController) this._modelLoadController.abort();
@@ -71,6 +147,9 @@ export class AnomalousBrowser {
this.stopMediaInContainer(this.grid);
this.grid.replaceChildren();
this.models = [];
// The output gallery reloads its first page when opened again.
this.galleryGrid.querySelectorAll('.anomalous-gallery-card').forEach(card => card.remove());
this.galleryImagesList = [];
}, 90000);
}
@@ -80,17 +159,19 @@ export class AnomalousBrowser {
}
}
AnomalousBrowser.prototype.initDoctorPanel = initDoctorPanel;
AnomalousBrowser.prototype.openDoctorPage = function () { openDoctorPage(this); };
AnomalousBrowser.prototype.showModelSources = function (scope) { showModelSources(this, scope); };
AnomalousBrowser.prototype.diagnoseNode = diagnoseNode;
AnomalousBrowser.prototype.renderGlobalDashboard = renderGlobalDashboard;
AnomalousBrowser.prototype.openCurrentNode = function () { openCurrentNode(this); };
AnomalousBrowser.prototype.initAssistantPanel = initAssistantPanel;
AnomalousBrowser.prototype.renderAssistantModelCard = renderAssistantModelCard;
AnomalousBrowser.prototype._loadAssistantHistory = _loadAssistantHistory;
AnomalousBrowser.prototype._openGalleryReplacer = _openGalleryReplacer;
AnomalousBrowser.prototype.openLoraInsertionPicker = openLoraInsertionPicker;
AnomalousBrowser.prototype.runGlobalDoctorScan = runGlobalDoctorScan;
AnomalousBrowser.prototype.showNotebooks = showNotebooks;
AnomalousBrowser.prototype.openCombo = openCombo;
AnomalousBrowser.prototype.openComboByFilename = openComboByFilename;
AnomalousBrowser.prototype.closeWorkspace = closeWorkspace;
AnomalousBrowser.prototype.refreshNotebooks = refreshNotebooks;
AnomalousBrowser.prototype.saveCurrentNotebook = saveCurrentNotebook;
@@ -100,15 +181,11 @@ AnomalousBrowser.prototype.fillNotebookGalleries = fillNotebookGalleries;
AnomalousBrowser.prototype.sendNotebookToCanvas = sendNotebookToCanvas;
AnomalousBrowser.prototype.showRecipes = showRecipes;
AnomalousBrowser.prototype.openRecipeByFilename = openRecipeByFilename;
AnomalousBrowser.prototype.refreshRecipes = refreshRecipes;
AnomalousBrowser.prototype.renderRecipeList = renderRecipeList;
AnomalousBrowser.prototype.handleSaveRecipe = handleSaveRecipe;
AnomalousBrowser.prototype.showMaterials = showMaterials;
AnomalousBrowser.prototype.openSavedMaterial = openSavedMaterial;
AnomalousBrowser.prototype.openMaterialLibrary = openMaterialLibrary;
AnomalousBrowser.prototype.openPromptStudio = openPromptStudio;
AnomalousBrowser.prototype.openPromptTranslator = function() { openPromptTranslator(this); };
AnomalousBrowser.prototype.refreshMaterials = refreshMaterials;
AnomalousBrowser.prototype.loadGalleryImages = loadGalleryImages;
AnomalousBrowser.prototype.refreshGalleryImages = refreshGalleryImages;
@@ -118,12 +195,10 @@ AnomalousBrowser.prototype.showGalleryViewer = showGalleryViewer;
AnomalousBrowser.prototype.showImageWorkbench = showImageWorkbench;
AnomalousBrowser.prototype.createDOM = createDOM;
AnomalousBrowser.prototype.openScanWizard = openScanWizard;
AnomalousBrowser.prototype.openScanPage = function (view) { openScanPage(this, view); };
AnomalousBrowser.prototype.scanSingleModel = triggerDirectModelScan;
AnomalousBrowser.prototype.openFolderManager = openFolderManager;
AnomalousBrowser.prototype.renderSidebar = renderSidebar;
AnomalousBrowser.prototype.loadFolders = loadFolders;
AnomalousBrowser.prototype.showHelp = showHelp;
AnomalousBrowser.prototype.hideAllPanels = hideAllPanels;
AnomalousBrowser.prototype.loadModels = loadModels;
+34 -23
View File
@@ -1,24 +1,16 @@
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('anomalous_btn_x');
localStorage.removeItem('anomalous_btn_y');
}
} catch (_) {}
import { app } from '../../../scripts/app.js';
import { AnomalousBrowser } from './browser.js';
import {
clampFloatingTriggerPosition,
clearSavedTriggerPosition,
DEFAULT_SAFE_LEFT,
DEFAULT_SAFE_TOP,
isValidSavedTriggerPosition,
DEFAULT_MIN_SAFE_X,
loadSavedTriggerPosition,
normalizeEntryMode,
normalizeFloatingTriggerSize,
normalizeFloatingTriggerStyle,
removeLegacyTriggerPosition,
saveTriggerPosition
} from './entry_controls.js?v=20260921-flicker-free-1';
} from './entry_controls.js';
import {
createShortcutSettingControl,
DEFAULT_BROWSER_SHORTCUT,
@@ -163,10 +155,18 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
ensureBrowser()?.show();
}
// The shortcut and the Extensions menu entry: open, or close when it is open.
function toggle() {
const browser = ensureBrowser();
if (browser?.modal?.classList.contains('visible')) browser.close();
else browser?.show();
}
// The second shortcut (its ids still say materials) opens Prompt Studio.
async function openMaterials() {
const browser = ensureBrowser();
if (!browser) return;
await browser.openMaterialLibrary();
await browser.openPromptStudio();
}
function getMaterialsShortcutCombo() {
@@ -178,9 +178,7 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
}
function materialLibraryIsOpen() {
return browserInstance?.modal?.classList.contains('visible') === true
&& browserInstance?.nbPanel?.style.display === 'flex'
&& browserInstance?.materialContainer?.style.display === 'flex';
return document.body.classList.contains('anomalous-prompt-studio-open');
}
function installMaterialsShortcutFallback() {
@@ -253,8 +251,14 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
refreshRegisteredSettings();
});
/** Size before the button is measurable; matches the size presets in 00-foundation-models.css. */
function estimatedTriggerSize() {
return floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60);
}
async function setup() {
const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=20260921-flicker-free-1';
removeLegacyTriggerPosition();
const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=20261005-keep-1';
if (!document.querySelector('link[href^="/extensions/Anomalous_Model_Browser/styles.css"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
@@ -287,7 +291,7 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
// Pre-calculate and assign exact target coordinates BEFORE appending to DOM
const savedInitialPos = loadSavedTriggerPosition();
if (savedInitialPos) {
const estSize = floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60);
const estSize = estimatedTriggerSize();
const initialClamped = clampFloatingTriggerPosition({
x: savedInitialPos.x,
y: savedInitialPos.y,
@@ -295,7 +299,7 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
height: estSize,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
minX: 70
minX: DEFAULT_MIN_SAFE_X
});
btn.style.position = 'fixed';
btn.style.left = initialClamped.x + 'px';
@@ -332,7 +336,7 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
height: btnH,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
minX: 70
minX: DEFAULT_MIN_SAFE_X
});
currentDragX = clamped.x;
currentDragY = clamped.y;
@@ -404,8 +408,7 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
const updateBtnBounds = () => {
const saved = loadSavedTriggerPosition();
if (saved) {
const estFallback = floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60);
const btnW = btn.offsetWidth || estFallback;
const btnW = btn.offsetWidth || estimatedTriggerSize();
const btnH = btn.offsetHeight || btnW;
const clamped = clampFloatingTriggerPosition({
x: saved.x,
@@ -414,7 +417,7 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
height: btnH,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
minX: 70
minX: DEFAULT_MIN_SAFE_X
});
btn.style.left = clamped.x + 'px';
btn.style.top = clamped.y + 'px';
@@ -439,6 +442,14 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
syncVisibility();
ensureBrowser();
installMaterialsShortcutFallback();
// For the Anomalous_TTS node's character menu (its docs/INTERFACE.md §6).
window.anomalous_open_voice = (character) => {
const browser = ensureBrowser();
if (!browser) return false;
browser.show();
browser.openVoice(String(character || ''));
return true;
};
// Reveal smoothly on first animation frame without any positional jump or flicker
requestAnimationFrame(() => {
@@ -460,7 +471,7 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) {
onClick: open
}],
commands: [
{ id: OPEN_BROWSER_COMMAND_ID, label: t('mainOpenTitle'), function: open },
{ id: OPEN_BROWSER_COMMAND_ID, label: t('mainOpenTitle'), function: toggle },
{ id: OPEN_MATERIALS_COMMAND_ID, label: t('mainOpenMaterialsTitle'), function: openMaterials },
{ id: RESET_TRIGGER_POSITION_COMMAND_ID, label: t('mainResetFloatingTriggerPosition'), function: resetPosition }
],
+16
View File
@@ -0,0 +1,16 @@
/**
* ComfyUI's Ctrl+Z history. It takes a step on its own events (a mouse button or key let
* go); a canvas write made by a drop (no mouse-up follows a drag), in a click handler or
* after a request finishes would otherwise land in no step, and Ctrl+Z would skip it.
* Call after every canvas write. Takes `app` so pure modules can use it.
*/
export function recordCanvasStep(app) {
try {
const tracker = app?.extensionManager?.workflow?.activeWorkflow?.changeTracker;
if (typeof tracker?.captureCanvasState === 'function') tracker.captureCanvasState();
else tracker?.checkState?.(); // older ComfyUI
} catch (error) {
console.warn('[AMB] Ctrl+Z history: step not recorded.', error);
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Undo from the activity log. When a canvas entry is recorded, what it did is kept here in
* full (the server shortens long values) for as long as the page stays open. An entry can
* be undone while everything it did is as it left it: the values it set unchanged since,
* the nodes it added still there and unconnected. Entries that removed nodes or rewired
* existing ones are never offered, nor anything from before the page was opened.
*/
import { valueText } from './activity_diff.js';
import { recordCanvasStep } from './canvas_history.js';
import { applyNodeMaterialValues, isVolatileWidget } from './node_material_actions.js';
const MAX_KEPT = 100;
const kept = new Map(); // entry id → { workflow, changes }
/** The open workflow, as entries name it. */
export function workflowKey(app) {
const workflow = app.extensionManager?.workflow?.activeWorkflow;
return String(workflow?.key || workflow?.path || workflow?.filename || '');
}
/** Keeps what entry `entryId` did (two snapshotGraph maps), when it can be undone at all. */
export function keepUndo(entryId, workflow, before, after) {
if (!entryId) return;
const changes = [];
for (const [id, now] of after) {
const was = before.get(id);
if (!was) {
changes.push({ kind: 'added', id, type: now.type });
continue;
}
if (was.links !== now.links) return;
for (const name of new Set([...Object.keys(was.widgets), ...Object.keys(now.widgets)])) {
if (was.widgets[name] === now.widgets[name]) continue;
if (!(name in was.widgets) || !(name in now.widgets)) return;
changes.push({ kind: 'changed', id, name, before: was.widgets[name], after: now.widgets[name] });
}
}
for (const id of before.keys()) if (!after.has(id)) return;
if (!changes.length) return;
kept.set(entryId, { workflow, changes });
while (kept.size > MAX_KEPT) kept.delete(kept.keys().next().value);
}
const nodeById = (graph, id) => graph?.getNodeById?.(Number(id)) ?? graph?.getNodeById?.(id) ?? null;
const connected = node => (node.inputs || []).some(input => input?.link != null)
|| (node.outputs || []).some(output => output?.links?.length);
/** A value from its text form, of the kind the widget holds now. */
function restoredValue(current, text) {
if (typeof current === 'string') return text;
try { return JSON.parse(text); } catch (_) { return text; }
}
/** What undoing entry `id` does now, or null when it cannot. */
function undoPlan(app, id) {
const record = kept.get(id);
const graph = app.canvas?.graph || app.graph;
if (!record || !graph || record.workflow !== workflowKey(app)) return null;
const values = new Map();
const remove = [];
for (const change of record.changes) {
const node = nodeById(graph, change.id);
if (!node) return null;
if (change.kind === 'added') {
if (node.type !== change.type || connected(node)) return null;
remove.push(node);
continue;
}
const index = (node.widgets || []).findIndex(widget => widget?.name === change.name);
const widget = node.widgets?.[index];
if (!widget || valueText(widget.value) !== change.after) return null;
if (isVolatileWidget(node, widget, index)) continue; // seeds are never written back
const value = restoredValue(widget.value, change.before);
const choices = typeof widget.options?.values === 'function' ? widget.options.values() : widget.options?.values;
if (Array.isArray(choices) && !choices.includes(value)) return null; // that choice is gone
if (!values.has(node)) values.set(node, []);
values.get(node).push({ index, value });
}
return values.size || remove.length ? { graph, values, remove } : null;
}
export function canUndo(app, entry) {
return Boolean(entry?.id && undoPlan(app, entry.id));
}
/** Puts back what the entry changed and removes the nodes it added; one Ctrl+Z step. */
export function undoEntry(app, entry) {
const plan = undoPlan(app, entry?.id);
if (!plan) throw new Error('activityUndoGone');
const { graph } = plan;
graph.beforeChange?.();
try {
for (const [node, values] of plan.values) applyNodeMaterialValues(app, node, values, { record: false });
for (const node of plan.remove) graph.remove(node);
} finally {
graph.afterChange?.();
}
graph.setDirtyCanvas?.(true, true);
kept.delete(entry.id);
recordCanvasStep(app);
}
+26
View File
@@ -0,0 +1,26 @@
/**
* A workflow laid on a graph of its own, off the canvas, to be read (an output image's
* workflow kept as a recipe or a combo). ComfyUI's frontend keeps widget values by the root
* graph's id, node and name, and a workflow saved from the open canvas carries the canvas's
* id: configured under that id, the detached graph would share the canvas's widget values and
* write its own onto them. So the copy gets an id of its own. No DOM.
*/
/** A random version-4 UUID; `crypto.randomUUID` exists only on https or this computer. */
function freshId() {
if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID();
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/** `workflow` (UI JSON, left untouched) on a new LiteGraph graph with an id of its own. */
export function detachedGraph(workflow) {
const copy = JSON.parse(JSON.stringify(workflow));
copy.id = freshId();
const graph = new globalThis.LiteGraph.LGraph();
graph.configure(copy);
return graph;
}
+10 -14
View File
@@ -1,11 +1,3 @@
// Unconditionally purge legacy contaminated storage keys on module load
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('anomalous_btn_x');
localStorage.removeItem('anomalous_btn_y');
}
} catch (_) {}
export const FLOATING_TRIGGER_SIZES = new Set(['small', 'medium', 'large']);
export const FLOATING_TRIGGER_STYLES = new Set(['icon', 'pill']);
export const ENTRY_MODES = new Set(['floating', 'topbar', 'menu']);
@@ -22,11 +14,11 @@ export function normalizeEntryMode(value) {
return ENTRY_MODES.has(value) ? value : 'floating';
}
// Unsaved buttons are placed by CSS (#anomalous-trigger-btn, 00-foundation-models.css);
// these mirror that default for clamping when no coordinates exist.
export const DEFAULT_SAFE_TOP = 80;
export const DEFAULT_SAFE_LEFT = 80; // Safe position outside ComfyUI left sidebar dock
export const DEFAULT_SAFE_MARGIN_RIGHT = 24;
export const DEFAULT_SAFE_LEFT = 80;
export const DEFAULT_MIN_SAFE_X = 70; // Avoid ComfyUI left sidebar dock
export const DEFAULT_TRIGGER_POSITION = Object.freeze({ top: DEFAULT_SAFE_TOP, left: DEFAULT_SAFE_LEFT });
export const TRIGGER_POSITION_STORAGE_KEY = 'anomalous_trigger_pos_v3';
export const LEGACY_TRIGGER_X_KEY = 'anomalous_btn_x';
export const LEGACY_TRIGGER_Y_KEY = 'anomalous_btn_y';
@@ -81,13 +73,17 @@ export function clearSavedTriggerPosition(storage = (typeof localStorage !== 'un
} catch (_) {}
}
export function sanitizeSavedTriggerPosition(x, y) {
export function normalizeSavedTriggerPosition(x, y) {
if (!isValidSavedTriggerPosition(x, y)) return null;
return { x: Number.parseFloat(x), y: Number.parseFloat(y) };
}
export function normalizeSavedTriggerPosition(x, y) {
return sanitizeSavedTriggerPosition(x, y);
/** Pre-v3 keys could hold coordinates inside the sidebar dock; they are never read, only removed. */
export function removeLegacyTriggerPosition(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) {
try {
storage?.removeItem(LEGACY_TRIGGER_X_KEY);
storage?.removeItem(LEGACY_TRIGGER_Y_KEY);
} catch (_) {}
}
export function clampFloatingTriggerPosition({
+93
View File
@@ -0,0 +1,93 @@
/**
* Feedback on GitHub: what was written in the feedback window (ui_feedback_dialog.js)
* opens as a new issue in the browser's language, with the environment folded at the end,
* and the same environment can be copied for other channels.
* The environment is versions and hardware only (plugin, ComfyUI, Python, PyTorch, GPU,
* browser, page, language): never a path, a file name or ComfyUI's command line.
* Nothing is sent anywhere; the person reads it in the issue before posting.
*/
import { isTtsInstalled } from './audio_engines.js';
import { translate } from './locales.js';
const t = (key, params) => translate(key, params);
const REPO = 'https://github.com/DemonGatanjieu/Anomalous_Model_Browser';
async function fetchJson(url) {
try {
const res = await fetch(url);
return res.ok ? await res.json() : null;
} catch {
return null;
}
}
/** "Chrome 141" / "Edge 140" / "Firefox 133" from the user agent, or the agent's last word. */
function browserName() {
const agent = navigator.userAgent || '';
for (const [name, pattern] of [['Edge', /Edg\/(\d+)/], ['Firefox', /Firefox\/(\d+)/], ['Chrome', /Chrome\/(\d+)/], ['Safari', /Version\/(\d+).*Safari/]]) {
const match = pattern.exec(agent);
if (match) return `${name} ${match[1]}`;
}
return agent.split(' ').pop() || 'unknown';
}
function gigabytes(bytes) {
return bytes ? `${Math.round(bytes / 1024 ** 3)} GB` : '';
}
/** The environment as lines of "name: value", for an issue or a chat. */
export async function collectDiagnostics(owner) {
const [version, stats, tts] = await Promise.all([
fetchJson('/anomalous/version'), fetchJson('/system_stats'), isTtsInstalled().catch(() => false),
]);
const state = version?.state || {};
const system = stats?.system || {};
const device = stats?.devices?.[0];
// "cuda:0 NVIDIA GeForce RTX 4050 Laptop GPU : cudaMallocAsync" -> the card's name.
const gpu = device ? String(device.name || '').replace(/^\S+:\d+\s+/, '').replace(/\s+:\s+\S+$/, '') : '';
const plugin = [state.label || state.tag || state.commit || 'unknown', state.branch && state.branch !== 'main' ? `(${state.branch})` : '',
state.dirty ? '(modified)' : ''].filter(Boolean).join(' ');
const lines = [
['Anomalous Model Browser', plugin],
['ComfyUI', [system.comfyui_version, window.__COMFYUI_FRONTEND_VERSION__ && `frontend ${window.__COMFYUI_FRONTEND_VERSION__}`].filter(Boolean).join(' · ')],
['Python / PyTorch', [String(system.python_version || '').split(' ')[0], system.pytorch_version].filter(Boolean).join(' / ')],
['GPU', [gpu, gigabytes(device?.vram_total)].filter(Boolean).join(' · ')],
['RAM', gigabytes(system.ram_total)],
['OS / Browser', [system.os, browserName()].filter(Boolean).join(' / ')],
['Anomalous_TTS', tts ? 'installed' : 'not installed'],
['Page / language', [owner?.currentShellPage?.(), window.anomalous_browser_lang].filter(Boolean).join(' / ')],
];
return lines.filter(([, value]) => value).map(([name, value]) => `- ${name}: ${value}`).join('\n');
}
/** "[Bug] the first words of what was written", short enough for a list of issues. */
function issueTitle(kind, text) {
const first = String(text || '').trim().split('\n')[0].trim();
const short = first.length > 60 ? `${first.slice(0, 59)}…` : first;
return `${t(kind === 'bug' ? 'feedbackIssueBugPrefix' : 'feedbackIssueIdeaPrefix')}${short}`;
}
/**
* Opens a new GitHub issue with what was written, in the browser's language; the
* environment, when attached, is folded at the end so the issue reads as the person wrote it.
*/
export async function openIssue(owner, kind, text, { environment = true } = {}) {
const parts = [String(text || '').trim()];
if (environment) {
parts.push('', `<details><summary>${t('feedbackIssueEnvironment')}</summary>`, '', await collectDiagnostics(owner), '', '</details>');
}
const query = new URLSearchParams({ title: issueTitle(kind, text), body: parts.join('\n') });
window.open(`${REPO}/issues/new?${query}`, '_blank', 'noopener');
}
/** Copies the environment; true when it reached the clipboard. */
export async function copyDiagnostics(owner) {
try {
await navigator.clipboard.writeText(await collectDiagnostics(owner));
return true;
} catch {
return false;
}
}
+112
View File
@@ -0,0 +1,112 @@
/**
* A gallery card's right-click menu: put the picture on the canvas as a Load Image node, or
* open its parameters. The picture is uploaded into the input folder the way a dropped file is
* (ComfyUI keeps one copy of the same picture): a "name.png [output]" value would be flagged
* as a missing file by ComfyUI's checks for any picture not made since ComfyUI started.
*/
import { app } from '../../../scripts/app.js';
import { translate as t } from './locales.js';
import { recordCanvasStep } from './canvas_history.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
import { canvasPoint, followPointer } from './notebook_canvas.js';
let closeOpenMenu = null;
function row(icon, label, run, close) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'anomalous-keep-row';
item.setAttribute('role', 'menuitem');
const mark = document.createElement('span');
mark.className = 'anomalous-keep-row-icon';
mark.textContent = icon;
const text = document.createElement('strong');
text.textContent = label;
item.append(mark, text);
item.onclick = (event) => {
close();
run(event);
};
return item;
}
/** The output picture uploaded into the input folder; its name there for a Load Image node. */
async function copyToInput(image) {
const query = new URLSearchParams({ filename: image.filename, subfolder: image.subfolder || '', type: 'output' });
const picture = await fetch(`/view?${query}`);
if (!picture.ok) throw new Error(`view ${picture.status}`);
const blob = await picture.blob();
const form = new FormData();
form.append('image', new File([blob], image.filename, { type: blob.type }));
form.append('type', 'input');
const response = await fetch('/upload/image', { method: 'POST', body: form });
if (!response.ok) throw new Error(`upload ${response.status}`);
const data = await response.json();
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
}
/**
* A Load Image node showing `image`: the browser closes and the node follows the pointer until a
* click puts it down (Esc takes it off again), like a combo's Put on canvas. `at` is the click
* that chose it, where the node starts.
*/
export async function insertLoadImageNode(owner, image, at) {
const node = globalThis.LiteGraph?.createNode('LoadImage');
const widget = node?.widgets?.find(item => item.name === 'image');
let file = null;
try {
if (node && widget && app.graph) file = await copyToInput(image);
} catch (error) {
console.error('[AMB] Could not copy the picture into the input folder:', error);
}
if (!file) {
showWorkbenchToast(t('galleryInsertNodeFailed'));
return;
}
const [x, y] = canvasPoint(at);
node.pos = [x - node.size[0] / 2, y - 20];
app.graph.add(node);
const choices = widget.options?.values;
if (Array.isArray(choices) && !choices.includes(file)) choices.push(file);
widget.value = file;
widget.callback?.(file); // shows the picture in the node
app.graph.setDirtyCanvas(true, true);
owner.close();
followPointer(owner, [{ node, relX: 0, relY: 0 }], () => recordCanvasStep(app));
showWorkbenchToast(t('galleryInsertNodeFollow'));
}
/** Opens the menu at the pointer; `openDetails()` opens the picture's parameters. */
export function openGalleryCardMenu(event, image, { owner, openDetails }) {
event.preventDefault();
closeOpenMenu?.();
const menu = document.createElement('div');
menu.className = 'anomalous-keep-menu anomalous-gallery-card-menu';
menu.setAttribute('role', 'menu');
const onPointer = (pointer) => { if (!menu.contains(pointer.target)) close(); };
const onKey = (key) => {
if (key.key !== 'Escape') return;
key.stopPropagation();
close();
};
const close = () => {
menu.remove();
window.removeEventListener('pointerdown', onPointer, true);
window.removeEventListener('keydown', onKey, true);
window.removeEventListener('wheel', close, true);
if (closeOpenMenu === close) closeOpenMenu = null;
};
menu.append(
row('🖼️', t('galleryInsertAsNode'), (click) => insertLoadImageNode(owner, image, click), close),
row('🔍', t('materialViewParameters'), openDetails, close),
);
document.body.append(menu);
const { width, height } = menu.getBoundingClientRect();
menu.style.left = `${Math.max(8, Math.min(event.clientX, window.innerWidth - width - 8))}px`;
menu.style.top = `${Math.max(8, Math.min(event.clientY, window.innerHeight - height - 8))}px`;
window.addEventListener('pointerdown', onPointer, true);
window.addEventListener('keydown', onKey, true);
window.addEventListener('wheel', close, true);
closeOpenMenu = close;
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Keeping an output image (the gallery's star, the image workbench) as one of three things:
* its whole workflow as a Workflow Recipe (recipe_save.js), its main model, LoRAs and
* positive prompt as a combo (搭配, a file of GET /anomalous/notebooks), or its prompts as a
* saved prompt (POST /anomalous/save_prompt_plan). The image's workflow is laid on a
* canvas of its own and read like a recipe saved from it (recipe_parser.js); its models are
* looked up among this computer's files. Each thing kept remembers its image, so
* GET /anomalous/kept_images can say what an image was kept as. No DOM.
*/
import { extractRecipeMetadata } from './recipe_parser.js';
import { categorizePromptSnippet, promptTitle } from './prompt_composition.js';
import { jsonResponse } from './ui_dom.js';
import { detachedGraph } from './detached_graph.js';
const MAIN_TYPES = ['checkpoints', 'unet', 'diffusion_models'];
// The combo editor lists models without a base model under this value (notebook_canvas.js).
const UNLABELED_BASE_MODEL = '__unlabeled__';
const post = (url, body) => fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
/** The key of an output image in the map loadKeptImages() returns. */
export const keptKey = image => `${image?.subfolder || ''}\n${image?.filename || ''}`;
/** Map(keptKey → { recipe, combo, prompt }), each { filename, name } or null. */
export async function loadKeptImages(signal) {
const response = await fetch('/anomalous/kept_images', { cache: 'no-store', signal });
const { images = [] } = await jsonResponse(response, 'list kept images');
return new Map(images.map(item => [keptKey(item), item]));
}
/** What the image's workflow uses (extractRecipeMetadata) and a name: its prompt's first tags, else the file's. */
async function readImage(sourceImage) {
const info = await jsonResponse(await post('/anomalous/inspect_image_material', { source_image: sourceImage }), 'read image workflow');
const metadata = extractRecipeMetadata(detachedGraph(info.workflow));
const name = promptTitle(metadata.promptPositive[0]) || String(sourceImage?.filename || '').replace(/\.[^.]+$/, '');
return { name, metadata };
}
/** { path → this computer's model } for the paths found among `types`. */
async function localModels(paths, types) {
if (!paths.length) return {};
const response = await post('/anomalous/resolve_paths_to_previews', { paths, folder_types: types });
const { models = {} } = await jsonResponse(response, 'look up models');
// The editor's model entries carry no absolute path.
return Object.fromEntries(Object.entries(models).map(([path, { file_path, ...model }]) => [path, model]));
}
/** Every combo: [{ filename, name, data }]. */
export async function listCombos() {
const response = await fetch('/anomalous/notebooks', { cache: 'no-store' });
return (await jsonResponse(response, 'list combos')).notebooks || [];
}
const sameImage = (a, b) => Boolean(a?.filename && b?.filename) && keptKey(a) === keptKey(b);
/** `name`, or `name 2`, `name 3`… so a new combo never overwrites another one's file. */
export function freeComboName(name, combos) {
const taken = new Set(combos.map(note => note.filename.toLowerCase()));
const base = String(name || '').trim().replace(/[\\/:*?"<>|]+/g, ' ').trim().slice(0, 100) || 'Combo';
for (let n = 1; ; n += 1) {
const candidate = n === 1 ? base : `${base} ${n}`;
if (!taken.has(`${candidate}.json`.toLowerCase())) return candidate;
}
}
/**
* Keeps the image's main model, LoRAs and positive prompt as a combo; an image kept before
* gives that combo back (`existed`). Models this computer does not have are left out and
* named in `missing`. Returns { filename, name, existed, missing }.
*/
export async function keepImageAsCombo(sourceImage, { name = '' } = {}) {
const combos = await listCombos();
const kept = combos.find(note => sameImage(note.data?.source_image, sourceImage));
if (kept) return { filename: kept.filename, name: kept.name, existed: true, missing: [] };
const image = await readImage(sourceImage);
const main = image.metadata.baseModel || '';
const loraPaths = [...new Set((image.metadata.loras || []).map(lora => lora.name).filter(Boolean))];
const [mains, loras] = await Promise.all([
localModels(main ? [main] : [], MAIN_TYPES),
localModels(loraPaths, ['loras']),
]);
const mainModel = mains[main] || null;
const comboName = freeComboName(name || image.name, combos);
const data = {
name: comboName,
baseModel: mainModel ? String(mainModel.metadata?.baseModel || '').trim() || UNLABELED_BASE_MODEL : '',
mainModel,
loras: loraPaths.filter(path => loras[path]).map(path => loras[path]),
promptEn: image.metadata.promptPositive.join('\n'),
promptZh: '',
source_image: sourceImage,
};
const filename = `${comboName}.json`;
const response = await post('/anomalous/save_notebook', { filename, name: comboName, data });
if ((await jsonResponse(response, 'save combo')).status !== 'success') throw new Error('combo save failed');
const missing = [...(main && !mainModel ? [main] : []), ...loraPaths.filter(path => !loras[path])];
return { filename, name: comboName, existed: false, missing };
}
/**
* Keeps the image's positive and negative prompts as one saved prompt; the same prompts
* saved before give that one back (`existed`). Returns { filename, name, existed }, or
* { empty: true } when the workflow has no prompt.
*/
export async function keepImagePrompts(sourceImage, { name = '' } = {}) {
const image = await readImage(sourceImage);
const positive = image.metadata.promptPositive.join('\n');
const negative = image.metadata.promptNegative.join('\n');
if (!positive.trim() && !negative.trim()) return { empty: true };
const promptName = String(name || image.name || sourceImage.filename).trim().slice(0, 120);
const parts = [['positive', positive], ['negative', negative]].filter(([, text]) => text.trim()).map(([role, text]) => ({
name: promptName,
category: role === 'negative' ? 'base' : categorizePromptSnippet(text),
role,
positive: role === 'positive' ? text : '',
negative: role === 'negative' ? text : '',
enabled: true,
}));
const response = await post('/anomalous/save_prompt_plan', {
name: promptName,
plan: { version: 2, positive, negative, parts },
source_image: sourceImage,
});
// The same prompts saved before come back as that saved prompt (409), not a copy.
const payload = response.status === 409 ? await response.json() : await jsonResponse(response, 'save prompts');
if (!['success', 'duplicate'].includes(payload.status)) throw new Error('prompt save failed');
return { filename: payload.filename, name: payload.name || promptName, existed: payload.status === 'duplicate' };
}
+10 -1
View File
@@ -92,7 +92,8 @@ function refreshRegisteredSettings() {
}
}
function applyLanguagePreference(value) {
/** 'auto' | 'zh' | 'en': the ComfyUI setting's change handler, also used without the settings API. */
export function applyLanguagePreference(value) {
const preference = normalizeLanguagePreference(value);
if (preference === 'auto') localStorage.removeItem('anomalous_lang');
else localStorage.setItem('anomalous_lang', preference);
@@ -109,6 +110,14 @@ function applyLanguagePreference(value) {
}
}
/**
* "Follow ComfyUI" (no language chosen here): takes up a ComfyUI language changed since the
* page loaded. Called when the browser opens, since ComfyUI switches its language without a reload.
*/
export function followComfyLanguage() {
if (!localStorage.getItem('anomalous_lang')) applyLanguagePreference('auto');
}
if (!localStorage.getItem('anomalous_lang')) {
currentLang = resolveComfyLanguage();
window.anomalous_browser_lang = currentLang;
+2309 -996
View File
File diff suppressed because it is too large Load Diff
+43 -13
View File
@@ -3,6 +3,7 @@ import { translate as t } from './locales.js';
import { text } from './ui_dom.js';
import { materialNodeHeading } from './material_inspector.js';
import { anomalousAlert } from './ui_dialog.js';
import { recordCanvasStep } from './canvas_history.js';
let activeDrag = null;
@@ -27,8 +28,19 @@ export function materialDropNode(event, canvas, graph) {
return node && graph.getNodeById(node.id) === node ? node : null;
}
/** Only this page's active drag can mutate a node; transfer data is never trusted. */
export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropOnCanvas }) {
/**
* Only this page's active drag can mutate a node; transfer data is never trusted.
* `accepts`, `targetHint`, `rejectHint` and `drop` also get the drag event, for what is
* under the pointer. Optional `targetHint(node, data, event)` says what dropping on an
* accepted node will do; optional `rejectHint(node, data, event)` explains why a hovered
* node is refused. Optional `onStart(data)` may return what to undo when the drag ends;
* optional `onMove(node, data, event)` follows the accepted node under the pointer (or null).
* Optional `passThrough(event)` leaves events over some part of the page (a drop zone of its
* own) alone; `effectAllowed` defaults to 'copy'. `owner` may be null (no browser to fold).
* After a drop handler finishes the canvas is recorded as a ComfyUI history step, so a drop
* survives a reload and Ctrl+Z takes it back even when the handler forgot to.
*/
export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropOnCanvas, targetHint, rejectHint, onStart, onMove, passThrough, effectAllowed = 'copy' }) {
element.draggable = true;
element.addEventListener('dragstart', event => {
if (event.target !== element && event.target?.closest?.('button, input, textarea, select')) { event.preventDefault(); return; }
@@ -39,13 +51,15 @@ export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropO
activeDrag?.();
event.stopPropagation();
event.dataTransfer.setData('application/x-anomalous-material', 'local-drag');
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.effectAllowed = effectAllowed;
const defaultHint = data.dragHint || t('materialDropHint');
const hint = text(document.body, 'div', defaultHint, 'anomalous-material-drag-hint');
hint.setAttribute('role', 'status');
const stopStart = onStart?.(data);
const cleanup = () => {
clearTimeout(reveal);
owner.modal?.classList.remove('anomalous-material-dragging');
stopStart?.();
owner?.modal?.classList.remove('anomalous-material-dragging');
hint.remove();
for (const [name, fn] of listeners) window.removeEventListener(name, fn, true);
if (activeDrag === cleanup) activeDrag = null;
@@ -59,39 +73,55 @@ export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropO
};
const target = event => app.graph === graph && app.canvas === canvas ? materialDropNode(event, canvas, graph) : null;
const over = event => {
if (passThrough?.(event)) {
hint.style.visibility = 'hidden';
onMove?.(null, data, event);
return;
}
hint.style.visibility = '';
event.preventDefault(); event.stopImmediatePropagation();
const node = target(event);
const validNode = !!node && accepts?.(node, data);
const validNode = !!node && accepts?.(node, data, event);
// A node that says why it refuses is not "blank canvas".
const refusal = node && !validNode ? rejectHint?.(node, data, event) || '' : '';
onMove?.(validNode ? node : null, data, event);
if (validNode) {
event.dataTransfer.dropEffect = 'copy';
hint.classList.add('is-target-valid');
hint.textContent = t('materialDropTarget', { name: materialNodeHeading(node) });
} else if (dropOnCanvas && isOverCanvasSurface(event)) {
hint.classList.remove('is-target-refused');
hint.textContent = targetHint?.(node, data, event) || t('materialDropTarget', { name: materialNodeHeading(node) });
} else if (!refusal && dropOnCanvas && isOverCanvasSurface(event)) {
event.dataTransfer.dropEffect = 'copy';
hint.classList.add('is-target-valid');
hint.textContent = data.dragTargetHint || defaultHint;
} else {
event.dataTransfer.dropEffect = 'none';
hint.classList.remove('is-target-valid');
hint.textContent = defaultHint;
hint.classList.toggle('is-target-refused', Boolean(refusal));
hint.textContent = refusal || defaultHint;
}
hint.style.left = `${Math.max(8, Math.min(event.clientX + 16, window.innerWidth - 250))}px`;
hint.style.top = `${Math.max(8, event.clientY - 48)}px`;
};
const finish = async event => {
if (passThrough?.(event)) {
cleanup();
return;
}
event.preventDefault(); event.stopImmediatePropagation();
const node = target(event);
const validNode = !!node && accepts?.(node, data);
const validNode = !!node && accepts?.(node, data, event);
const refused = node && !validNode && Boolean(rejectHint?.(node, data, event));
const overCanvas = isOverCanvasSurface(event);
cleanup();
if (validNode) {
try { await drop(node, data, graph); }
try { await drop(node, data, graph, event); recordCanvasStep(app); }
catch (error) { await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); }
return;
}
if (dropOnCanvas && overCanvas) {
if (!refused && dropOnCanvas && overCanvas) {
const pos = getCanvasPosition(event, canvas);
try { await dropOnCanvas(event, data, graph, pos); }
try { await dropOnCanvas(event, data, graph, pos); recordCanvasStep(app); }
catch (error) { await anomalousAlert(t(error.message) === error.message ? t('recipeOpenError') : t(error.message)); }
return;
}
@@ -99,7 +129,7 @@ export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropO
const escape = event => { if (event.key === 'Escape') cleanup(); };
const listeners = [['dragover', over], ['drop', finish], ['dragend', cleanup], ['keydown', escape], ['blur', cleanup]];
// Let the browser capture the card's drag image before revealing the canvas.
const reveal = setTimeout(() => owner.modal?.classList.add('anomalous-material-dragging'), 0);
const reveal = setTimeout(() => owner?.modal?.classList.add('anomalous-material-dragging'), 0);
activeDrag = cleanup;
for (const [name, fn] of listeners) window.addEventListener(name, fn, true);
});
-37
View File
@@ -1,37 +0,0 @@
import { translate as t } from './locales.js';
import { anomalousAlert } from './ui_dialog.js';
/** Shared save receipt; navigation is owned by the browser instance. */
export function showMaterialSaved(owner, material, beforeOpen) {
document.querySelector('.anomalous-material-receipt')?.remove();
const receipt = document.createElement('div');
receipt.className = 'anomalous-material-receipt';
receipt.setAttribute('role', 'status');
const message = document.createElement('span');
message.textContent = t('materialSavedToLibrary');
receipt.appendChild(message);
if (typeof owner?.openSavedMaterial === 'function' && material?.filename) {
const view = document.createElement('button');
view.type = 'button';
view.textContent = t('materialViewSaved');
view.onclick = async () => {
view.disabled = true;
try {
beforeOpen?.();
await owner.openSavedMaterial(material);
receipt.remove();
} catch (error) {
await anomalousAlert(t('materialDetailLoadError'));
view.disabled = false;
}
};
receipt.appendChild(view);
}
const close = document.createElement('button');
close.type = 'button';
close.textContent = '×';
close.setAttribute('aria-label', t('materialDismissReceipt'));
close.onclick = () => receipt.remove();
receipt.appendChild(close);
document.body.appendChild(receipt);
}
-42
View File
@@ -3,10 +3,6 @@ import { text, jsonResponse } from './ui_dom.js';
import { translate } from './locales.js';
const t = (key, params) => translate(key, params);
export function sectionLabel(parent, value) {
return text(parent, 'div', value, 'anomalous-material-section-label');
}
export function fileBaseName(value) {
return String(value || '').replace(/\\/g, '/').split('/').pop();
}
@@ -395,44 +391,6 @@ export function mergePromptRoleOverrides(promptRoles, overrides) {
return roles;
}
export function promptGroupsFromBlocks(blocks) {
const positive = [];
const negative = [];
for (const block of Array.isArray(blocks) ? blocks : []) {
const value = (block.widgets_values || []).find(item => typeof item === 'string' && item.trim());
if (!value) continue;
const textVal = value.trim();
if ((block.promptRole === 'positive' || block.promptRole === 'both') && !positive.includes(textVal)) positive.push(textVal);
if ((block.promptRole === 'negative' || block.promptRole === 'both') && !negative.includes(textVal)) negative.push(textVal);
}
return { positive, negative };
}
export function renderMaterialPromptGroups(parent, groups, options = {}) {
const positive = Array.isArray(groups?.positive) ? groups.positive.filter(Boolean) : [];
const negative = Array.isArray(groups?.negative) ? groups.negative.filter(Boolean) : [];
if (!positive.length && !negative.length) return false;
const renderCard = (titleText, promptStr, tone) => {
const card = document.createElement('div');
card.className = tone ? `anomalous-material-prompt-card is-${tone}` : 'anomalous-material-prompt-card';
const bar = document.createElement('div');
bar.className = 'anomalous-material-prompt-bar';
text(bar, 'span', titleText, 'anomalous-material-prompt-bar-label');
const copyBtn = text(bar, 'button', t('materialCopyPrompt'), 'anomalous-material-mini-btn');
copyBtn.type = 'button';
bindCopyButton(copyBtn, () => promptStr, 'materialCopied');
card.appendChild(bar);
text(card, 'div', promptStr, 'anomalous-material-prompt-content is-expanded');
parent.appendChild(card);
};
if (positive.length) renderCard(t('materialPositivePrompt'), positive.join('\n\n'), 'positive');
if (negative.length) renderCard(t('materialNegativePrompt'), negative.join('\n\n'), 'negative');
if (options.manual) text(parent, 'p', t('materialPromptRoleFromRecipe'), 'anomalous-material-muted');
return true;
}
function bindCopyButton(button, value, successKey) {
const label = button.textContent;
button.onclick = async event => {

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